Files
callstack__agent-device/docs/adr/0021-host-simlock-managed-device-allocation.md
Michał Pierzchała 0bebbe8dc2 feat: admit managed requests within lease authority (#2319)
* feat(daemon): admit managed requests within lease authority

* test: verify managed request authority and activation boundaries

* test: run managed request admission in provider integration
2026-09-06 08:10:59 +02:00

23 KiB

ADR 0021: Host — Simlock-Backed Managed Device Allocation and the Host Supervisor

Status

Accepted (2026-09-01). The local managed-device slice and the upstream dependencies tracked in Simlock #70 gate implementation. This ADR owns the architecture and safety invariants; contracts, registries, schemas, CLI help, and conformance tests become the executable sources of truth.

1. Context

agent-device proxy is a thin reverse proxy in front of one local daemon: full JSON-RPC passthrough, one shared bearer token, client-declared tenant identity, and upload/artifact routes. Remote device leases (ADR 0007), resumable uploads, tenant-scoped sessions and artifacts, RPC versioning (ADR 0006), and request-bound platform runtimes (ADR 0019) already exist.

A shared, self-maintaining Mac also needs per-user identity, shape allocation, managed device lifecycle, capacity, component preparation, supervision, retention, and safe maintenance. callstackincubator/simlock already owns most of that local allocator problem behind Apple and Android drivers. It is local by design: its clients use a Unix socket and its grants contain host-local UDIDs or ADB serials. It does not own remote HTTP, tenant authorization, Host sessions, artifacts, or automation transport.

Putting another provisioning engine in agent-device would create competing state machines. Making Simlock the public remote provider would force it to recreate Host. The chosen composition keeps one owner for each problem.

2. Decision

  1. Proxy and Host are distinct exposure policies. agent-device proxy remains the ad-hoc shared-token front-end. agent-device host adds per-user authentication, managed allocation, and supervision over the daemon protocol.
  2. Simlock is the sole managed-device allocator. It owns shape resolution, component preparation, provisioning, capacity, warm inventory, boot/shutdown policy, health, quarantine, reclaim, deletion, and allocator recovery. Agent-device has no parallel device manifest, capacity counter, provisioning queue, or destructive fallback.
  3. The lease layers stay distinct. A Host device lease authorizes a remote user. A Simlock managed-device lease owns local allocation exclusivity and lifecycle. Agent-device records their mapping but never treats one as the other.
  4. One managed runtime binds each grant. The outward runtime owner is selected once. It delegates automation mechanics to the Apple or Android runtime and all managed-device lifecycle/readiness work to Simlock. Handlers never resolve a second lifecycle owner or fall back to direct simctl, adb, or emulator lifecycle commands.
  5. Simlock remains a local implementation detail. Remote clients connect to Host over HTTPS; Host connects to a co-resident Simlock over its supported local client contract. No public connection profile, leaseProvider, or runtime plugin ABI exposes Simlock.
  6. Authentication terminates at the Host front-end. It strips all client-supplied identity and injects the authenticated principal { tenantId, userId, credentialId } over a daemon-token-authenticated loopback channel.
  7. Administration is a separate boundary. Host operator methods and Simlock administration are loopback-only, daemon-credential-authenticated capabilities. Public route and method allowlists deny them explicitly.
  8. Name-only device selection allocates a shape. On Host, --device "iPhone 16" requests a managed shape; the client sends that shape instead of resolving it to an inventory UDID first. An explicit UDID/device key addresses an existing unmanaged device and is refused if it belongs to the managed pool. Managed identities are absent from public inventory and ordinary selector resolution.
  9. Preparation is explicit. Missing components are never downloaded because of an untrusted lease request. Authorized install/admin policy asks Simlock to prepare them, with disk admission, durable progress, and a terminal result.
  10. Artifacts remain Host-owned. Simlock owns no tenant uploads, artifacts, diagnostics, or retention policy.
  11. Maintenance crosses one deep boundary. Host proves execution quiescence; one durable, idempotent Simlock maintenance epoch owns allocator drain, release, settle, reconciliation, restart recovery, and resume. Host never choreographs those internal lifecycle steps.
  12. Pinned updates preserve the process boundary. Host preflights a compatible agent-device and Simlock pair, retains the previous pair, and supports rollback. Repository colocation does not merge their authority or durable state.

3. Ownership and runtime composition

local integration or remote Host request
  -> agent-device daemon
      -> one managed local-runtime binding
          -> Apple/Android automation mechanics
          -> Simlock client over a Unix socket
              -> managed simulator or emulator
Owner Authority
Host front-end TLS, token authentication, asserted principal, public status
agent-device daemon User authorization, Host sessions/leases, request admission, artifacts, allocation-operation journal, managed runtime binding
Simlock Shape resolution, preparation, managed-device leases, lifecycle, capacity, health, warm inventory, and GC
Apple/Android runtime Automation mechanics delegated by the managed runtime, excluding managed-device lifecycle
Host supervisor Process recovery and scheduled maintenance through the two local admin boundaries

Four independent guards remain because they protect different things:

Guard Purpose
Host device lease Remote user authorization and attribution
Simlock managed-device lease Allocation exclusivity and destructive lifecycle authority
Managed-device execution claim Excludes non-Simlock-aware agent-device daemons from the local identity
Platform helper/runner lease Protects the automation helper process below the managed runtime

The managed runtime is the only outward ADR 0019 runtime owner. Its internal delegation is not a handler-visible fallback. Registry completeness gates reject any command path that can bypass the binding for managed-device readiness, boot, shutdown, erase, reclaim, or deletion. Ordinary app and session teardown still belongs to agent-device; device lifecycle does not.

The local-first slice binds this runtime without Host HTTP, identity, or artifacts. Host later adds authorization and attribution around the same boundary. Plain local device selection and plain proxy retain their existing behavior; this ADR does not create connect simlock.

4. Allocation and recovery contract

Host journal versus allocator state

Before acquisition, agent-device durably records a non-authoritative allocation operation: the logical requester, idempotency key, immutable shape request, deadline, and Host attribution when applicable. After Simlock responds, it records the allocator handle/outcome. Before invoking an external Host binding publisher, it durably records a pending publication; publication success is then recorded separately, and recovery conservatively cleans a pending or uncertain binding before releasing the allocator lease. This journal exists only to recover the Host-to-Simlock handoff. It never mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it never decides whether a device is reusable.

Each logical requester is a restart-stable allocation lane; concurrent leases use distinct lanes. Replaying the same attempt key returns the same durable outcome, including a refusal. Disconnect, request timeout, or abort abandons only the caller's wait. A request terminalizes through explicit cancellation, authorized supersession, or a stored allocation outcome that can no longer publish a grant. Capacity, disk, validation, and provisioning failures are terminal for that attempt even while any device settlement remains capacity-bearing. An uncertain result is always reconciled through Simlock lookup.

A terminal failure is never re-evaluated under the same attempt key. Retrying after a retriable capacity refusal uses a new key after retryAfterMs; retrying a disk refusal uses a new key only after operator remediation. Simlock admits that key as the next generation without supersession because the prior generation can never grant. A nonterminal prior generation still requires authorized supersession.

Replacing a nonterminal attempt or live binding on the same lane requires agent-device to fence the old binding, drain admitted commands, complete canonical session teardown, and prove no runner can still mutate the device. Simlock then atomically supersedes exactly the expected prior generation and installs the replacement attempt. Replays return the same outcome; stale generations or different replacement payloads fail without mutation. This permits capacity-one crash healing without letting a new key revoke live work.

Host asks Simlock for fail-fast allocation. Capacity refusal maps to retriable ADR 0010 details.reason: "simulator-capacity" with retryAfterMs; Host adds no second queue. Simlock performs disk admission before every create, clone, or authorized component download. Low disk maps to non-retriable details.reason: "disk-low" without retryAfterMs; Host adds no second disk heuristic.

Execution claims and reusable Android identities

An ordinary device claim ends with its session or command. A managed-device execution claim instead lasts for the allocator-owned identity's full pool lifetime and is cleared only after Simlock proves that identity removed. Active managed sessions execute under it rather than replacing it. Stale claim recovery revalidates Simlock ownership and never performs direct lifecycle cleanup.

The claim store therefore gains an explicit allocator-backed claim kind with its own durable owner and release rules. It is not encoded as a synthetic session and cannot inherit session-close clearing or stale-process cleanup without allocator revalidation.

Reusable Android identities require a fenced activation handshake: Simlock reserves the identity, agent-device acquires or reattaches its execution claim, and Simlock validates the clean baseline under that fence before the grant is published. A conflicting ordinary claim prevents publication. The execution claim remains through idle/reclaim intervals, so a known serial cannot be mutated by another agent-device daemon between Host leases. This extra guard is execution exclusion only; Simlock remains the allocator and lifecycle owner.

Renewal, release, and restart

Before admitting a command, Host confirms that the Simlock lease covers the command deadline plus canonical teardown. Renewals are allocator-first, amortized outside the safety window, and coalesced per managed-device lease. A failed or uncertain renewal fences only the affected Host/managed-device binding, reconciles through Simlock, and either publishes the confirmed deadline or tears down. No command continues on Host's cached deadline alone.

The daemon's lease-admission service is createManagedLeaseAdmission under src/daemon/managed-device-allocation/. One instance belongs to one binding incarnation; its coordinator must fence it before release, replacement, or supersession. managedCommandHorizon reuses the command's request envelope and reserves the canonical teardown budget including recording finalization. Unbounded commands require a bounded child request before managed execution. An admitted result reports only allocator-confirmed authority; teardown-required leaves that binding permanently fenced. Budget reservation is not proof of cleanup or runner quiescence. Request runtime binding accepts a matching lease service and command horizon from its coordinator. Exact managed binding admits the allocator-held claim and confirms that horizon before native bind probes; readiness activates only after the binding is adopted and its requested operations are admitted. The managed runtime owner dispatches each reviewed operation inside lease admission. Request disposal cancels pending admissions and revokes readiness before cleanup begins, while shared renewal and late-binding cleanup retain their existing owners. Unconfigured managed requests remain refused. This seam does not provide a publication/recovery coordinator, which must use canonical teardown before returning the allocation.

Release is durable and retryable. Host does not publish a replacement grant while Simlock may still mutate the device. After either daemon restarts, the journal is reconciled through Simlock lookup: a live, authorized mapping reattaches; missing, terminal, stale, or unauthorized work follows canonical cleanup. Simlock remains the only source of capacity and lifecycle truth, and Host status projects rather than reconstructs it.

5. Shape and isolation policy

A managed shape is { platform, deviceType, osVersion? }. Simlock owns canonical resolution against its installed catalog. Omitted OS selects the newest installed runtime; an explicit major selects the newest compatible installed minor; an exact version must match or enter explicit preparation.

Host v1 includes both iOS and Android, with deliberately asymmetric isolation:

  • iOS receives a new simulator UDID per Host lease and deletes it after release.
  • Android starts from Simlock's validated clean baseline but may reuse the same serial.

Clean-state reuse is not fresh identity. Help and public or operator-facing documentation must qualify every freshness claim by platform. Changing either guarantee requires an ADR amendment.

Warm inventory remains Simlock-owned. Simlock computes admission against every capacity dimension a warm device consumes—including platform managed/running limits and the global running limit—and reserves a cold-demand slot in each applicable dimension. Capacity remains charged until eviction/deletion is confirmed. Current defaults live in versioned install configuration and help, not in this ADR.

host install creates a dedicated Simlock home and writes paired versioned configuration. Simlock owns its socket/home, validated iOS device-set root, capacity and warm policy, iOS fresh-identity policy, Android clean-baseline policy, and disk floor. The Host adapter owns compatible protocol range and renewal/teardown timing. Requests cannot override any of them.

Preparation commands remain ADR 0019 platformExecution: host operations behind a neutral allocator-admin service; command handlers do not import either platform driver.

6. Identity, authorization, and revocation

One Host deployment serves one tenant with attributed users; multi-tenant deployment uses separate Hosts. Reads are tenant-wide. Mutation of a session, Host lease, upload, artifact, or diagnostic is authorized by ownerUserId, so any active credential for that user can continue the work. Forced release or termination exists only on the admin surface.

Every attributed resource stores immutable ownerUserId and createdByCredentialId; each request audits actedByCredentialId. Live execution resources also store controlCredentialId, which is the emergency-revocation fence. Revocation does not delete durable uploads, artifacts, or diagnostics; their mutation remains user-authorized.

Credential states are active, retired, and revoked. Rotation creates a new credential, changes all of that user's live controlCredentialId values in one daemon transaction, and only then retires the old front-end credential. A failure leaves the old credential active rather than orphaning work. Already-admitted work may finish; rotation does not trigger emergency teardown.

One daemon HostAdmissionCoordinator serializes credential state and the maintenance epoch before every Host heartbeat, session mutation, runtime binding, or device operation, then indexes admitted work by credential, user, and resource. Existing resource locks run after this admission point.

Revocation linearizes at a durable daemon credential fence. From that point, even an already authenticated and forwarded request cannot begin execution. The front-end registry is then marked revoked, and host tokens revoke returns only after both boundaries acknowledge the fence. Work already admitted is canceled or drained; credential-controlled sessions and leases follow durable, retryable teardown. Cleanup failure is observable and may quarantine ownership, but never weakens the security fence.

The principal and attribution fields persist on sessions, Host leases, uploads, artifacts, diagnostics, allocation operations, and mappings. The built-in token registry runs at the outer front-end, where the original credential is visible; it is not mounted behind the daemon auth hook. The hook contract must carry the complete trusted principal or fail compatibility negotiation—no identity is inferred downstream.

7. Maintenance, artifacts, and supervision

Refill/preparation, maintenance, reconciliation, token administration, and forced termination are loopback-only admin methods. Attributed status requires a user token; anonymous /health remains minimal.

The scheduled maintenance window may end healthy leases after a configurable allocation grace and may be disabled. When it runs, the durable supervisor:

  1. Persists a maintenance epoch, fences new allocations, then fences and drains device mutations.
  2. Ends Host leases, performs canonical session teardown, releases their managed-device leases, and proves zero live runner. Only the local managed adapter receives the resulting unforgeable quiescence capability; it calls Simlock prepare-maintenance with the epoch alone.
  3. Simlock internally drains every lifecycle producer, releases remaining leases, settles, reconciles only ownership-verified devices, and persists a restart-safe drained result.
  4. Sweeps only expired, unpinned artifacts inside the Host artifact root. Unknown expiry and symlinked entries are refused. No global DerivedData, temporary, or developer-device roots are maintenance targets. Caller-path delete-after-download state remains in memory and never becomes a durable sweeper entry.
  5. If configured, restarts CoreSimulatorService, Simlock, and agent-device in that order. The persisted epoch starts Host fenced and Simlock drained.
  6. Invokes Simlock resume for the same epoch. Simlock completes startup reconciliation and advances warm convergence until ready or until a reusable identity needs its external execution claim.
  7. While Host remains fenced, the local managed adapter acquires or reattaches every requested managed-device execution claim and idempotently confirms fenced activation. Replaying resume continues the same epoch until Simlock reports warm convergence ready.
  8. The supervisor runs both doctors, retains the report, emits failure/disk webhooks, then removes the Host epoch and reopens admission.

The Host epoch has no TTL. Supervisor death leaves Host fenced and Simlock drained until the restarted supervisor or loopback-only operator recovery completes the same epoch. A configuration may disable the whole window or non-destructive suffixes; it cannot enable destructive work without the safety prefix. A different prepare epoch fails without mutation while one is active, and stale resume/activation messages cannot clear or advance a newer epoch.

Continuous artifact GC remains Host-owned. Continuous managed-device GC remains Simlock-owned. The supervisor owns process recovery and scheduling but exposes no network service.

8. Protocol and packaging

Host authentication semantics and server-side shape-allocation sequencing require an agent-device RPC protocol bump under ADR 0006. Plain proxy behavior does not change.

Simlock exposes a supported, typed, versioned local client for every operation Host consumes. Version skew fails before mutation. Host never shells out to the Simlock CLI, imports Simlock core internals, or dynamically loads an arbitrary provider module.

The adapter's package location is an implementation choice under R13/R65. Its semantic contract, single managed runtime owner, lifecycle exclusion, and protocol compatibility are not.

9. Consequences and rejected alternatives

The composition adds a second daemon and a versioned local protocol, but removes an entire duplicate allocator/lifecycle state machine from agent-device. Local execution can reuse Simlock before Host exists, while ordinary local devices and external providers keep their current lease models.

  • Provision inside agent-device: rejected because it duplicates Simlock's capacity, recovery, quarantine, warm inventory, and destructive ownership.
  • Expose Simlock as a remote device provider: rejected because Simlock grants a local identity; it does not own remote transport, tenant authorization, artifacts, or automation behavior.
  • Replace every agent-device lease with Simlock: rejected because Host authorization, local lifecycle ownership, peer-execution exclusion, and helper mutual exclusion are different guards.
  • Integrate only after Host exists: rejected because the local managed-runtime slice proves the hard lifecycle boundary before adding HTTP and identity.
  • Load arbitrary provider modules: rejected because that needs a separate trusted plugin model, stable public ABI, config/secrets contract, lifecycle, compatibility policy, and acceptance of daemon-privileged code execution.

10. Acceptance invariants

Implementation issue checklists may refine operation-level acceptance without weakening the following lasting architecture invariants.

The local slice must exercise iOS and Android acquire, open, automation, close, release, renewal, restart, cancellation, same-requester healing, concurrent requester lanes, and contention from both Simlock-aware and ordinary agent-device daemons. It must prove that no managed identity becomes executable before its claim and no Android reusable identity becomes unclaimed between leases.

Final acceptance requires:

  1. No duplicate, unattributed, or over-capacity allocation across disconnects, retries, or either daemon restarting.
  2. No command starts without authorization or beyond a confirmed managed-device lease horizon; revocation linearizes before cleanup and maintenance fails closed across supervisor death.
  3. iOS fresh identity, Android clean reuse, explicit preparation, disk admission, and ownership-safe deletion hold under adversarial races and state corruption.
  4. One client connection independently renews concurrent managed-device leases up to capacity; capacity-one replacement proceeds only after the prior execution binding is quiescent.

Conformance covers identity spoofing, authorization, public-admin denial, revoke/request races, allocation cancellation and supersession, lost responses, long-command renewal, low disk, unauthorized downloads, corrupted state, cleanup quarantine, Android idle-serial exclusion, maintenance death/restart, and paired-version rollback.

11. Open implementation choices

  • Exact wire and persisted record schemas, migrations, and descriptor representation.
  • Exact package placement for Host and the managed Simlock adapter.
  • Whether the supported Simlock client ships as one package export, a separate package, or a workspace package.