Skip to content

simlock device add / remove: an admin enrolls and unenrolls physical devices #437

Closed

Description

@V3RON

Part of #428.

Scope

After this PR, an admin can enroll a physical device with simlock device add --platform ios|android <id> and unenroll it with simlock device remove <id>. Both run on the host the device is plugged into: a single host or a worker. They work over the CLI, over HTTP and through the admin client. On a gateway both answer UNSUPPORTED_IN_GATEWAY_MODE, with a message to run the command on the worker. Each accepted case of the feature's enrollment table writes one record under the device's canonical ID. Each refused case answers with its reason and writes nothing. An agent session gets FORBIDDEN. Enrollment records the apps the driver lists for the device; removal changes nothing on the device. status, list --devices and the console show the device, marked physical.

Only the fake drivers inspect a device in this PR. The real iOS and Android drivers refuse every ID until tasks 4 and 5 add their inspection. No request can lease a physical device yet (task 2), and absent does not exist yet (task 3).

In short

An operator names a device once and Simlock remembers it. Simlock asks the platform's driver to read the device over USB. It writes the record only if the driver says the device can be enrolled. Running device add again on an enrolled device records its apps anew. device remove only forgets the device; it sends nothing to it.

New terms:

  • canonical ID: the one ID Simlock stores for a physical device, whatever ID the operator typed. For an iPhone it is the UDID, e.g. 00008110-001A2B3C4D5E, even when the operator typed its CoreDevice identifier.
  • re-enrollment: device add of a device that already has a record that is not deleted. It keeps the record and its registry ID, and replaces the model, OS version, class and app list with a fresh read.
  • enrollment claim: the device operation claim a re-enrollment holds from its read to its write. While it is held, nothing else reclaims, retries, grants or removes that device.
flowchart TD
  A["device add --platform ios ID (new)"] --> V{"ID of a Simlock simulator or emulator?"}
  V -->|yes| R1["DEVICE_NOT_ENROLLABLE virtual-device"]
  V -->|no| I["driver.inspectPhysical(ID) (new)"]
  I -->|refused| R2["DEVICE_NOT_ENROLLABLE, driver's reason"]
  I -->|canonical ID C| E{"record for C?"}
  E -->|none| W["write record, ready (new)"]
  E -->|leased, reclaiming or claimed| B["DEVICE_BUSY"]
  E -->|ready or quarantined| CL["take enrollment claim, inspect C again (new)"]
  CL --> W2["rewrite record, ready, reenrolled (new)"]
Loading

Before and after (wording is illustrative; each fact in a line is required):

# before
$ simlock device add --platform ios 00008110-001A2B3C4D5E
{"error":{"code":"USAGE","message":"Unknown command: device ..."}}

# after
$ simlock device add --platform ios 00008110-001A2B3C4D5E
Enrolled iPhone 15 Pro (iOS 18.4) as 00008110-001A2B3C4D5E, 12 user apps recorded.

$ simlock device add --platform ios 7A1C0E52-6B7D-4F1E-9C1A-2D3E4F5A6B7C
Enrolled iPhone 15 Pro (iOS 18.4) as 00008110-001A2B3C4D5E again, 13 user apps recorded.

$ simlock device add --platform android R58M123ABC
{"error":{"code":"DEVICE_NOT_ENROLLABLE","message":"R58M123ABC has not authorized USB debugging for this host"}}

$ simlock device remove 00008110-001A2B3C4D5E
Removed 00008110-001A2B3C4D5E. It is no longer enrolled; nothing on the device changed.
Input Today After this PR
device add --platform ios <UDID>, driver reads it enrollable USAGE enrolled in ready, apps recorded, device.enrolled
device add --platform ios <alias> of an enrolled device USAGE re-enrollment of the same record, reenrolled: true
device add <id> of a ready or quarantined device USAGE re-enrollment; a quarantined device's quarantine fields are cleared
device add <id> of a leased device USAGE DEVICE_BUSY leased
device add or device remove of a reclaiming device, or one whose reclaim claim is held USAGE DEVICE_BUSY reclaiming
device add or device remove while another device add holds the device USAGE DEVICE_BUSY busy
device add the driver refuses USAGE DEVICE_NOT_ENROLLABLE with the driver's reason and message
device add of a device the driver reads as locked USAGE DEVICE_NOT_ENROLLABLE locked ("locked: unlock it and try again")
device add <id> of a Simlock simulator or emulator, by its driverDeviceId or its address (e.g. emulator-5554) USAGE DEVICE_NOT_ENROLLABLE virtual-device, driver not asked
device add --platform android emulator-5556, an emulator that is not Simlock's USAGE DEVICE_NOT_ENROLLABLE network-only, from the Android driver (task 5); until then not-connected
device add --platform android X, X enrolled as an iOS device USAGE DEVICE_NOT_ENROLLABLE enrolled-on-other-platform
device add <id> without --platform USAGE USAGE: say which platform
device add or device remove from an agent session, over the CLI or HTTP USAGE FORBIDDEN
device remove <canonical ID> of a ready or quarantined device USAGE deleted; device.deleted operator; nothing sent to the device; no confirmation and no --yes
device remove <id> with no record, or a deleted one USAGE UNKNOWN_DEVICE
device remove <id> of a leased device USAGE DEVICE_BUSY leased
device add after device remove of the same device USAGE a new record, reenrolled: false; the old one stays deleted
device add ... on a worker's own daemon USAGE enrolled on that worker, as on a single host; the gateway's list --devices shows the device on that worker
device add ... or device remove ... on a gateway USAGE UNSUPPORTED_IN_GATEWAY_MODE, with a message to run it on the worker the device is plugged into; no worker is asked
lease --platform ios with an iPhone enrolled a simulator a simulator, as today
status --json or list --devices with a device enrolled, any session role — its entry carries driverDeviceId, the canonical ID device remove takes

If this goes wrong, an operator sees two records for one device, a device enrolled while the driver refused it, an agent token that can enroll, or a removal that sends a command to the device.

Technical spec

Modules touched

flowchart LR
  FE["CLI, HTTP app, admin client *"] --> WD["daemon/dispatcher, server *"]
  FE --> GD["gateway/dispatcher *<br/>(refuses both)"]
  WD --> PE["core/physical-enrollment (new)"]
  PE --> REG["core/registry *"]
  PE --> DRV["Driver: inspectPhysical (new);<br/>ios, android, fake drivers *"]
  PE --> CLM["core/device-operation-claims *"]
  QC["core/quarantine-coordinator *"] --> CLM
  UI["ui: worker-detail *"]
Loading

* changed, (new) added.

sequenceDiagram
  participant O as Operator CLI
  participant W as Daemon (single host or worker)
  participant E as PhysicalEnrollment
  participant D as Driver
  O->>W: device.add {platform, id}
  W->>W: role check (admin)
  W->>E: add(platform, id)
  E->>E: refuse a Simlock simulator or emulator (virtual-device)
  E->>D: inspectPhysical(id), no claim held
  alt refused
    D-->>E: refused {reason, message}
    E-->>O: DEVICE_NOT_ENROLLABLE (driver's reason and message)
  else enrollable, canonical ID C
    D-->>E: C, model, OS, class, apps
    alt no record for C
      E->>E: write record, ready
    else record for C is ready or quarantined
      E->>E: take enrollment claim
      E->>D: inspectPhysical(C)
      D-->>E: model, OS, class, apps
      E->>E: rewrite record, ready, then release the claim
    end
    E-->>O: {id, driverDeviceId, ..., reenrolled}
  end
  Note over O,W: on a gateway, device.add and device.remove answer UNSUPPORTED_IN_GATEWAY_MODE
Loading
  • src/contract/operations.ts: declares device.add and device.remove (role admin, effect write) and adds both to OPERATIONS.
  • src/contract/errors.ts: adds DEVICE_NOT_ENROLLABLE, DEVICE_BUSY and UNKNOWN_DEVICE to ErrorDetailsMap, CODES_WITH_DECLARED_DETAILS_BY_CODE and ERROR_TABLE.
  • src/contract/protocol.ts: protocol +1 at merge. This PR changes contract shapes (two operations, three error codes, driverDeviceId on the status entry), and every such change bumps the version (toolchain.md, "Contract shapes"). The reason is not written as a comment (architecture rule 17 forbids history comments); it is this: a client on the new version that sends device.add to a daemon on the previous one fails hello with PROTOCOL_VERSION_UNSUPPORTED, instead of the older daemon's UNKNOWN_REQUEST (src/daemon/server.ts:1195).
  • src/core/driver.ts: declares inspectPhysical(id): Promise<PhysicalInspection> on Driver (ADR 0022 §5); this task is its first caller. PhysicalInspection is either { enrollable: true, driverDeviceId, model, osVersion, class, apps } or { enrollable: false, reason, message }. driverDeviceId is the canonical ID. class is phone or tablet. apps are the app IDs the driver lists as the enrollment app list (tasks 4 and 5 say which apps per platform). reason is a DriverRefusalReason: every DEVICE_NOT_ENROLLABLE reason except enrolled-on-other-platform, which only the core can know. virtual-device is both: the core refuses Simlock's own simulators and emulators in step 1, and a driver refuses any other virtual device it reads (task 4). A driver answers a failed or timed-out read with unreadable and a message naming the read (tasks 4 and 5), not a throw. The driver decides every refusal it returns and writes its message; the core only relays it (architecture rule 2). No other physical-only method is declared here: task 2 declares listPresentPhysical and PhysicalDriverDevice.
  • src/drivers/ios/index.ts, src/drivers/android/index.ts: inspectPhysical refuses every ID with reason not-connected and the message "this driver cannot read physical devices yet". It starts no process. Tasks 4 and 5 replace it.
  • src/core/physical-enrollment.ts (new): PhysicalEnrollment, with add(platform, id) and remove(id). createCore builds it and exposes it as core.enrollment; src/core/index.ts exports its types. Steps of add:
    1. A non-deleted virtual record whose driverDeviceId or address is id is refused virtual-device, before any driver call. So Simlock's own emulator is refused by its AVD name and by its current adb serial.
    2. No driver for the platform: NO_DRIVER, as for a lease.
    3. inspectPhysical(id), with no claim held. This read needs no claim: reading changes nothing. The claim is held from the read under it (step 5) to the write (step 6). ADR 0022 §2 says "from its first read to its write"; its wording is being amended to match these steps. A refusal is answered as DEVICE_NOT_ENROLLABLE with the driver's reason and message. A throw is a driver bug and is answered INTERNAL. Neither writes anything.
    4. In one decision-gate section, find the non-deleted physical record with the canonical ID C. None: write a new record in ready and stop. One on the other platform: enrolled-on-other-platform. leased: DEVICE_BUSY leased. reclaiming, or claimed reclaim: DEVICE_BUSY reclaiming. Claimed for anything else: DEVICE_BUSY busy. Otherwise (ready or quarantined) take the enrollment claim.
    5. inspectPhysical(C) under the claim. A refusal or a throw is answered as in step 3 and leaves the record as it was. A result that names another canonical ID writes nothing and answers INTERNAL.
    6. In one decision-gate section, rewrite the record from the step-5 read: model, OS version, class and enrolledApps replaced, ready, the quarantine fields cleared, registry ID unchanged.
    7. Release the claim on every exit of steps 4 to 6, throws included (architecture rule 12).
      remove runs in one decision-gate section. No non-deleted physical record with that canonical ID: UNKNOWN_DEVICE. leased, reclaiming or claimed: DEVICE_BUSY as in step 4. Otherwise write deleted. It makes no driver call.
  • src/core/device-operation-claims.ts: DeviceOperation gains enrollment.
  • src/core/registry.ts: three writes for physical records: enroll (new record), re-enroll, unenroll. Each emits its event after its commit (events rule 3). Every device.deleted the registry emits gains physical, read from the record. This covers deleteQuarantined (:431), markDeviceMissing (:526), endLeaseAndMarkDeviceMissing (:549) and #emitDeviceEvent (:876), which emits the device.deleted of ReclaimCoordinator (:240) and ManagedDeviceLifecycle (:143, :225).
  • src/core/quarantine-coordinator.ts #retry (:175): takes the device's reclaim claim before its first driver call and releases it after its last write, on every exit. It releases the claim right after that write, before it emits device.quarantine-recovered (:206) and before every notifyAvailability() it calls (:210, :229, :283). This is the order LeaseReleaseCoordinator uses: release, then wake (src/leasing/lease-release-coordinator.ts:303-308). The planner never grants a claimed device (src/leasing/acquisition-planner.ts:89), and the warm pool does not count a claimed device as idle (src/core/warm-pool/policy.ts:369). So a wake-up under the claim would pass over the recovered device. A claim already held makes it re-arm after retryBackoffMs with its attempt count unchanged. This applies to every device, virtual included.
  • src/bus/index.ts: device.enrolled in EventMap; physical: boolean on device.deleted.
  • src/core/fake-driver.ts and e2e/fake-driver/ (types.ts, fake-driver.ts): scripted physical devices per platform. Each entry lists its IDs (the first is canonical, the rest are aliases), model, OS version, class and apps, or a refusal: a DriverRefusalReason and a message. A test can change an entry's model, OS version, class and apps between calls. An ID no entry lists is refused not-connected. inspectPhysical joins the call log, latencyMs and failures.
  • src/daemon/dispatcher.ts: device.add and device.remove call core.enrollment, on a single host and on a worker alike. src/daemon/server.ts: a switch case for each. Dispatcher#decorateDevice (src/daemon/dispatcher.ts:843) sets driverDeviceId on a physical device's status entry, for every session role.
  • src/gateway/dispatcher.ts: both operations answer UNSUPPORTED_IN_GATEWAY_MODE through unsupportedByDesign (:540), as component.list does (:190). The message says the command acts on a device plugged into one machine and to run it on the worker the device is plugged into. The gateway asks no worker.
  • src/http/app.ts: POST /v1/devices with a strict body { platform, id } dispatches device.add. DELETE /v1/devices/:id dispatches device.remove. The role check stays the dispatcher's.
  • src/simlock-client/client.ts, src/simlock-client/types.ts, src/admin/index.ts: addDevice and removeDevice on SimlockAdminClient, and their input and output types. simlock/client gets neither.
  • src/cli/index.ts: the device command: device add --platform <ios|android> <id> [--json] and device remove <id> [--json]. Without --json it prints one line, as in "In short"; with it, the operation's output. A refusal prints the error line, as every command does. device remove asks for no confirmation and has no --yes: it changes nothing on the device, so it is not a destructive command under safety rule 5.
  • ui/src/views/worker-detail.tsx: the devices table gains a "Type" column, "Physical" or "Virtual". A physical device shows "—" for Mode and Image tag.
  • New docs content: a simlock device add / device remove section and three exit-code rows in docs/CLI.md, saying both run on the host the device is plugged into and are refused on a gateway; both routes in docs/HTTP-API.md; both methods in docs/CLIENT.md; a PhysicalEnrollment row in docs/internal/COMPONENTS.md.

Contract and event changes

  • device.add, role admin. Input { platform, id }. id: 1 to 200 characters, no whitespace or control character, not starting with -. Output { id, driverDeviceId, platform, model, osVersion, class, appCount, reenrolled }. id is the registry ID.
  • device.remove, role admin. Input { id }, same bounds; id is the canonical ID. Output { id, driverDeviceId }.
  • New error codes:
    • DEVICE_NOT_ENROLLABLE { platform, id, reason, canonicalId? }, exit 12, HTTP 422. id is the ID as typed. canonicalId is set when the core knows it: for enrolled-on-other-platform, and for a refusal of the second inspection (step 5). reason is one of not-connected, network-only, untrusted, developer-mode-off, os-too-old, unsupported-kind, locked, unreadable, virtual-device, enrolled-on-other-platform. The core picks enrolled-on-other-platform, and virtual-device for its own devices; the driver picks every other reason. Tasks 4 and 5 map each row of the feature's enrollment table to one.
    • DEVICE_BUSY { id, reason }, exit 11, HTTP 409. reason is leased, reclaiming or busy.
    • UNKNOWN_DEVICE { id }, exit 12, HTTP 404.
  • statusDeviceSchema (src/contract/schemas.ts:145) gains driverDeviceId, present only on a physical device: its canonical ID. Every session role gets it, agents included. The worker view carries it (src/contract/schemas.ts:1070), so a gateway's list --devices shows it too. The schema's comment (:134) keeps driverDeviceId off for a virtual device, for its old reason. It gains why a physical device differs: its canonical ID is how an operator names it to device remove, and it names a device on the desk, not a driver address only its holder can use.
  • Protocol +1 at merge, for all of the above.
  • New event device.enrolled { deviceId, platform, driverDeviceId, model, osVersion, class, appCount, reenrolled }. Emitter: Registry. Both EVENTS.md files.
  • device.deleted: initiator gains operator; the payload gains physical on every emission (additive, events rule 6). Its meaning becomes "removed from the registry, and from disk for a virtual device". Both EVENTS.md files.

Other code on the same state

The state is a physical device record and its operation claim. All registry writes run inside the one decision gate (src/core/create-core.ts:47), so two writes never interleave. The claim covers the driver calls between them.

  • QuarantineCoordinator#retry (src/core/quarantine-coordinator.ts:175). Today it calls the driver with no claim, and its commit throws when the device left quarantined meanwhile (src/core/registry.ts:389). After this PR it takes the reclaim claim for every device, and whoever claims first wins. In this PR only virtual devices have retries: 1a arms none for a physical record. So a retry and an add or remove of one device first meet in task 2, which arms physical retries and tests that meeting. The rule they will follow is set here: a retry that holds the claim makes add and remove answer DEVICE_BUSY reclaiming; a retry that finds the claim held waits one backoff, with its attempt count unchanged, and then finds the device not quarantined (:182) if an add or remove committed meanwhile.
  • Doctor's live-drift check (src/core/doctor.ts:843) skips a claimed device. A retry now holds a claim on a quarantined simulator or emulator. That changes nothing: a quarantined device has no expected run state (:1215), so the check never compared it. The retry commits ready before it releases the claim, so the next doctor run compares the device as usual. registry-device-missing (:851) ignores claims, as today: a device gone from its root is still reported during a retry.
  • QuarantineCoordinator.restore (:142) re-arms retries at startup for quarantined records only. A re-enrolled or removed record is not one.
  • ReclaimCoordinator (src/core/reclaim-coordinator.ts:161) and LeaseReleaseCoordinator (src/leasing/lease-release-coordinator.ts:292) hold reclaim while a reclaim runs. add and remove answer DEVICE_BUSY reclaiming; the reclaim wins.
  • AcquisitionPlanner (src/leasing/acquisition-planner.ts:89) never grants a claimed device. In this PR no request fits a physical device (1a); from task 2 on, a device under the enrollment claim is not granted.
  • Doctor's stalled-transition finding (src/core/doctor.ts:952) skips claimed devices. The enrollment claim is held only on ready or quarantined devices, which are not in a transition, so the two never meet. Doctor's drift checks never read a physical record (1a).
  • StartupConverger (src/core/startup-converger.ts:99) recovers only unclaimed reclaiming devices, which add and remove refuse anyway.
  • The warm pool converger (src/core/warm-pool/converger.ts:687) passes on device.deleted; 1a makes the pass leave physical devices alone.
  • WorkerLink (src/gateway/worker-link.ts:587) refreshes a worker's view on every device.* event. So a device enrolled or removed on a worker shows on the gateway after the worker's device.enrolled or device.deleted reaches it.
  • Claims are in memory. A daemon that stops during add loses the claim; the record is either written or not. Nothing on the device needs undoing, because inspection only reads.

Rules in play

  • safety.md rule 1 (as ADR 0022 amends it): the enrollment record is the only ownership. add reads; remove sends nothing.
  • safety.md rule 8 (amended): a device is enrolled because an admin ran device add, never because it is plugged in.
  • safety.md rule 10: id is bounded before use.
  • architecture.md rules 2 and 10: the driver decides and words every refusal. The busy check is one helper used by add and remove. The role check stays in runDispatch.
  • architecture.md rule 12: every exit of add and of a quarantine retry releases its claim.
  • events.md rules 1, 3, 6, 8.
  • testing.md rules 1 to 3.

Tests

Main seam: the daemon run with the out-of-process fake driver (withDaemon), in a new e2e/physical-enrollment.test.ts, modelled on e2e/lease-lifecycle.test.ts and, for the agent session, e2e/component-install.test.ts:214. Gateway e2e: modelled on e2e/gateway-component-install.test.ts, which checks a gateway's UNSUPPORTED_IN_GATEWAY_MODE (:162). Claims and timers: createCore with FakeDriver, FakeClock and a real Registry, modelled on src/core/create-core.test.ts. A quarantine retry meeting a waiting request and the warm pool: createLeasing's harness, modelled on src/leasing/create-leasing.test.ts:533.

  • (e2e) device add --platform ios <UDID> of a scripted device exits 0 and prints one line with its model, OS version, canonical ID and app count, and no again
  • (e2e) after device add, status --json lists the device ready, physical: true, with its class, and the events hold one device.enrolled with reenrolled: false and the app count
  • (e2e) device add --json prints the registry ID, canonical ID, model, OS version, class, app count and reenrolled
  • (e2e) device add naming an enrolled device's alias keeps its registry ID, prints its line with again, emits device.enrolled with reenrolled: true, and list --devices holds one non-deleted record with that canonical ID
  • (e2e) each refusal the fake scripts (not-connected, network-only, untrusted, developer-mode-off, os-too-old, unsupported-kind, locked, unreadable, virtual-device) answers DEVICE_NOT_ENROLLABLE with that reason, the driver's message and id as typed, exits 12, and writes no record and no device.enrolled
  • (e2e) a client whose hello range ends at the previous protocol version is refused by the daemon with PROTOCOL_VERSION_UNSUPPORTED, so it never sends device.add to a daemon that would answer UNKNOWN_REQUEST
  • (e2e) device add of an ID no scripted device lists answers DEVICE_NOT_ENROLLABLE not-connected
  • (e2e) device add --platform ios naming a Simlock simulator's driverDeviceId, and device add --platform android naming a Simlock emulator's driverDeviceId and, separately, its address, each answer virtual-device, and the driver log has no inspectPhysical call
  • (e2e) device add --platform android of an ID enrolled as iOS answers enrolled-on-other-platform with canonicalId set, and leaves the iOS record as it was
  • (e2e) device add <id> without --platform exits 2 with USAGE, and the driver log has no inspectPhysical call
  • (e2e) device add on a daemon with no Android driver answers NO_DRIVER for --platform android
  • (e2e) device add and device remove from an agent session answer FORBIDDEN; the driver log has no inspectPhysical call and the record is unchanged
  • (e2e) device remove <canonical ID> of a ready device exits 0 and prints one line saying it is no longer enrolled and nothing on the device changed; list --devices shows the record deleted; the events hold one device.deleted with initiator: "operator" and physical: true; the driver log has no call after the remove
  • (e2e) device remove of an ID with no record, and of a removed device, answers UNKNOWN_DEVICE and exits 12
  • (e2e) device add after device remove writes a new record with a new registry ID and reenrolled: false, and the old record stays deleted
  • (e2e) after an iPhone is enrolled, lease --platform ios is granted a simulator, not the enrolled device
  • (e2e) after device add, status --json from an operator session and from an agent session, and list --devices, show the device's canonical ID as driverDeviceId; a simulator's status entry has no driverDeviceId
  • (e2e) on a gateway, after device add on worker w1's own daemon, the gateway's list --devices shows the device on w1 with its driverDeviceId, once w1's device.enrolled has reached the gateway
  • (e2e) POST /v1/devices with an operator token enrolls the device; with an agent token it is 403 FORBIDDEN and no inspectPhysical call is made; a body with a worker field, or any other unknown field, is 400 BAD_REQUEST
  • (e2e) DELETE /v1/devices/<canonical ID> with an operator token unenrolls the device; with an agent token it is 403 FORBIDDEN and the record stays ready
  • (e2e) on a gateway, device add and device remove each answer UNSUPPORTED_IN_GATEWAY_MODE and exit 2, with a message saying to run the command on the worker the device is plugged into; no worker's driver log has an inspectPhysical call
  • (core) after add, the record's enrolledApps equal the fake's scripted apps for that device
  • (core) a re-enrollment after the fake's entry changed model, OS version, class and apps leaves the record with the new model, OS version, class and apps, and the same registry ID
  • (core) two add calls of the same new device at once leave one non-deleted record and two device.enrolled, the second with reenrolled: true
  • (core) re-enrolling a quarantined physical device makes it ready with no quarantine fields and the same registry ID
  • (core) removing a quarantined physical device makes it deleted
  • (core) add and remove of a leased device answer DEVICE_BUSY leased and change nothing
  • (core) add and remove of a reclaiming device, and of a ready device whose reclaim claim the test holds, answer DEVICE_BUSY reclaiming
  • (core) while an add waits on its second inspection, another add and a remove of that device answer DEVICE_BUSY busy
  • (core) a virtual device's quarantine retry that finds its claim held makes no driver call, keeps its attempt count, and runs one retryBackoffMs later
  • (core) a virtual device's quarantine retry holds the reclaim claim while its driver reclaim runs, and the claim is free after the retry succeeds and after the driver call throws
  • (core) the claim is free before device.quarantine-recovered is emitted and before notifyAvailability() runs, after a retry that recovers the device, one that deletes it, and one that gives up
  • (leasing) a lease request waiting at capacity for a quarantined simulator's spec is granted that simulator, with no boot, once its quarantine retry succeeds
  • (leasing) with a warm-pool target of one for a quarantined simulator's spec, the pool pass started by the retry's device.quarantine-recovered reports that target's ready as 1
  • (core) doctor, run while a quarantined simulator's retry holds its claim, reports no live-drift finding for it, and reports registry-device-missing once the fake no longer lists it, as without the claim
  • (core) an add whose second inspection is refused, or throws, leaves the record as it was, and a remove right after it succeeds
  • (core) an add whose first inspection throws answers INTERNAL and writes nothing
  • (core) an add whose second inspection names another canonical ID answers INTERNAL and writes nothing, and a remove right after it succeeds
  • (core) after a re-enrollment succeeds, a second add of the device re-enrolls it again, and a remove right after that succeeds
  • (registry) every device.deleted the registry emits carries physical from its record: false from deleteQuarantined, markDeviceMissing, endLeaseAndMarkDeviceMissing and transitionDevice on a virtual record, true from unenroll
  • (contract) device.add and device.remove are admin in the role table of src/contract/operations.test.ts
  • (contract) an id of 201 characters, one with a space, and one starting with - are each BAD_REQUEST
  • (types, src/core/driver.test.ts) a PhysicalInspection refusal with reason enrolled-on-other-platform fails to compile (@ts-expect-error)
  • (cli) simlock device with an unknown subcommand, device add with two IDs, and device remove --yes <id> exit 2 with USAGE; device --help prints both forms
  • (e2e) device remove <id> from a non-interactive shell, with no --yes, unenrolls the device
  • (drivers) the real iOS and Android drivers' inspectPhysical refuses an ID not-connected and starts no process (src/drivers/ios/index.test.ts, src/drivers/android/index.test.ts)
  • (console) ui/src/views/worker-detail.test.tsx: the devices table shows "Physical" for a physical device and "Virtual" for a simulator, and "—" for the physical device's Mode and Image tag

Done when

  • pnpm test:e2e e2e/physical-enrollment.test.ts passes: with the fake driver, device add enrolls a scripted iPhone, status --json shows it ready and physical, and one device.enrolled is recorded.
  • With the fake driver, each scripted refusal answers DEVICE_NOT_ENROLLABLE with its reason, and no record is written.
  • An agent session's device add and device remove, over the CLI and over HTTP, answer FORBIDDEN, and the driver is never asked.
  • device remove of a ready device leaves it deleted, records device.deleted with initiator: "operator" and physical: true, and the driver log shows no call for it.
  • An alias of an enrolled device re-enrolls the same record; list --devices never shows two non-deleted records with one canonical ID.
  • On a gateway, device add and device remove answer UNSUPPORTED_IN_GATEWAY_MODE, with a message to run them on the worker, and no worker is asked.
  • PROTOCOL_VERSION_RANGE (src/contract/protocol.ts:47) is one above main's when this PR merges.
  • simlock list --devices on a gateway, and simlock status --json from an agent session, show an enrolled device's canonical ID as driverDeviceId.
  • The console's worker page marks a physical device "Physical".
  • Both EVENTS.md files list device.enrolled, and device.deleted with operator and physical.
  • docs/CLI.md, docs/HTTP-API.md and docs/CLIENT.md document both commands, both routes and both methods.

Out of scope

  • The real iOS and Android inspection: tasks 4 and 5. Until then device add against a real driver is refused not-connected (above).
  • lease --physical, the uninstall reclaim, unenrolling when quarantine retries run out, and waking waiting requests when a device is enrolled: task 2.
  • Quarantine retries of a physical device, and how they meet device add and device remove: task 2 arms them and tests that meeting.
  • The absent state, re-enrolling an absent device, and the physical-device watcher: task 3.
  • Routing physical requests on a gateway: task 6.
  • The end-user docs on what a release reclaims, re-enrolling after installing apps, and enrolling a device in one instance only: task 2.
  • Any guard against two Simlock instances enrolling one device.
  • Enrolling or removing a device through a gateway: there is no --worker option and no gateway relay. An operator runs device add and device remove on the worker the device is plugged into.

Depends on

Approval

  • Approved for delivery

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 10, 2026
  2. V3RON commented on Oct 10, 2026

    @V3RON
    ContributorAuthor

    Spec updated: findings of the first spec check fixed (Technical spec, Tests, Done when, Depends on).

    Written by an agent.

  3. V3RON commented on Oct 10, 2026

    @V3RON
    ContributorAuthor

    Spec updated: findings of the second spec check fixed, and the maintainer's decisions of 2026-10-10 folded in (Scope, In short, Technical spec, Tests, Done when).

    Written by an agent.

  4. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 11, 2026
  5. github-actions commented on Oct 11, 2026

    @github-actions
    Contributor

    Now task:ready: approved, technical spec present, #435, #436 closed.

  6. self-assigned this
    on Oct 11, 2026
  7. 2 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions