Repository navigation
Description
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 addof a device that already has a record that is notdeleted. 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)"]
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 *"]
* 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
src/contract/operations.ts: declaresdevice.addanddevice.remove(roleadmin, effectwrite) and adds both toOPERATIONS.src/contract/errors.ts: addsDEVICE_NOT_ENROLLABLE,DEVICE_BUSYandUNKNOWN_DEVICEtoErrorDetailsMap,CODES_WITH_DECLARED_DETAILS_BY_CODEandERROR_TABLE.src/contract/protocol.ts: protocol +1 at merge. This PR changes contract shapes (two operations, three error codes,driverDeviceIdon 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 sendsdevice.addto a daemon on the previous one failshellowithPROTOCOL_VERSION_UNSUPPORTED, instead of the older daemon'sUNKNOWN_REQUEST(src/daemon/server.ts:1195).src/core/driver.ts: declaresinspectPhysical(id): Promise<PhysicalInspection>onDriver(ADR 0022 §5); this task is its first caller.PhysicalInspectionis either{ enrollable: true, driverDeviceId, model, osVersion, class, apps }or{ enrollable: false, reason, message }.driverDeviceIdis the canonical ID.classisphoneortablet.appsare the app IDs the driver lists as the enrollment app list (tasks 4 and 5 say which apps per platform).reasonis aDriverRefusalReason: everyDEVICE_NOT_ENROLLABLEreason exceptenrolled-on-other-platform, which only the core can know.virtual-deviceis 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 withunreadableand 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 declareslistPresentPhysicalandPhysicalDriverDevice.src/drivers/ios/index.ts,src/drivers/android/index.ts:inspectPhysicalrefuses every ID with reasonnot-connectedand 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, withadd(platform, id)andremove(id).createCorebuilds it and exposes it ascore.enrollment;src/core/index.tsexports its types. Steps ofadd:- A non-deleted virtual record whose
driverDeviceIdoraddressisidis refusedvirtual-device, before any driver call. So Simlock's own emulator is refused by its AVD name and by its current adb serial. - No driver for the platform:
NO_DRIVER, as for a lease. 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 asDEVICE_NOT_ENROLLABLEwith the driver's reason and message. A throw is a driver bug and is answeredINTERNAL. Neither writes anything.- In one decision-gate section, find the non-deleted physical record with the canonical ID
C. None: write a new record inreadyand stop. One on the other platform:enrolled-on-other-platform.leased:DEVICE_BUSYleased.reclaiming, or claimedreclaim:DEVICE_BUSYreclaiming. Claimed for anything else:DEVICE_BUSYbusy. Otherwise (readyorquarantined) take theenrollmentclaim. 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 answersINTERNAL.- In one decision-gate section, rewrite the record from the step-5 read: model, OS version, class and
enrolledAppsreplaced,ready, the quarantine fields cleared, registry ID unchanged. - Release the claim on every exit of steps 4 to 6, throws included (architecture rule 12).
removeruns in one decision-gate section. No non-deleted physical record with that canonical ID:UNKNOWN_DEVICE.leased,reclaimingor claimed:DEVICE_BUSYas in step 4. Otherwise writedeleted. It makes no driver call.
- A non-deleted virtual record whose
src/core/device-operation-claims.ts:DeviceOperationgainsenrollment.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). Everydevice.deletedthe registry emits gainsphysical, read from the record. This coversdeleteQuarantined(:431),markDeviceMissing(:526),endLeaseAndMarkDeviceMissing(:549) and#emitDeviceEvent(:876), which emits thedevice.deletedofReclaimCoordinator(:240) andManagedDeviceLifecycle(:143, :225).src/core/quarantine-coordinator.ts#retry(:175): takes the device'sreclaimclaim 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 emitsdevice.quarantine-recovered(:206) and before everynotifyAvailability()it calls (:210, :229, :283). This is the orderLeaseReleaseCoordinatoruses: 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 afterretryBackoffMswith its attempt count unchanged. This applies to every device, virtual included.src/bus/index.ts:device.enrolledinEventMap;physical: booleanondevice.deleted.src/core/fake-driver.tsande2e/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: aDriverRefusalReasonand a message. A test can change an entry's model, OS version, class and apps between calls. An ID no entry lists is refusednot-connected.inspectPhysicaljoins the call log,latencyMsandfailures.src/daemon/dispatcher.ts:device.addanddevice.removecallcore.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) setsdriverDeviceIdon a physical device's status entry, for every session role.src/gateway/dispatcher.ts: both operations answerUNSUPPORTED_IN_GATEWAY_MODEthroughunsupportedByDesign(:540), ascomponent.listdoes (: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/deviceswith a strict body{ platform, id }dispatchesdevice.add.DELETE /v1/devices/:iddispatchesdevice.remove. The role check stays the dispatcher's.src/simlock-client/client.ts,src/simlock-client/types.ts,src/admin/index.ts:addDeviceandremoveDeviceonSimlockAdminClient, and their input and output types.simlock/clientgets neither.src/cli/index.ts: thedevicecommand:device add --platform <ios|android> <id> [--json]anddevice remove <id> [--json]. Without--jsonit prints one line, as in "In short"; with it, the operation's output. A refusal prints the error line, as every command does.device removeasks 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 removesection and three exit-code rows indocs/CLI.md, saying both run on the host the device is plugged into and are refused on a gateway; both routes indocs/HTTP-API.md; both methods indocs/CLIENT.md; aPhysicalEnrollmentrow indocs/internal/COMPONENTS.md.
Contract and event changes
device.add, roleadmin. 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 }.idis the registry ID.device.remove, roleadmin. Input{ id }, same bounds;idis the canonical ID. Output{ id, driverDeviceId }.- New error codes:
DEVICE_NOT_ENROLLABLE { platform, id, reason, canonicalId? }, exit 12, HTTP 422.idis the ID as typed.canonicalIdis set when the core knows it: forenrolled-on-other-platform, and for a refusal of the second inspection (step 5).reasonis one ofnot-connected,network-only,untrusted,developer-mode-off,os-too-old,unsupported-kind,locked,unreadable,virtual-device,enrolled-on-other-platform. The core picksenrolled-on-other-platform, andvirtual-devicefor 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.reasonisleased,reclaimingorbusy.UNKNOWN_DEVICE { id }, exit 12, HTTP 404.
statusDeviceSchema(src/contract/schemas.ts:145) gainsdriverDeviceId, 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'slist --devicesshows it too. The schema's comment (:134) keepsdriverDeviceIdoff for a virtual device, for its old reason. It gains why a physical device differs: its canonical ID is how an operator names it todevice 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:initiatorgainsoperator; the payload gainsphysicalon 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 leftquarantinedmeanwhile (src/core/registry.ts:389). After this PR it takes thereclaimclaim 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 anaddorremoveof 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 makesaddandremoveanswerDEVICE_BUSYreclaiming; a retry that finds the claim held waits one backoff, with its attempt count unchanged, and then finds the device notquarantined(:182) if anaddorremovecommitted 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: aquarantineddevice has no expected run state (:1215), so the check never compared it. The retry commitsreadybefore 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 forquarantinedrecords only. A re-enrolled or removed record is not one.ReclaimCoordinator(src/core/reclaim-coordinator.ts:161) andLeaseReleaseCoordinator(src/leasing/lease-release-coordinator.ts:292) holdreclaimwhile a reclaim runs.addandremoveanswerDEVICE_BUSYreclaiming; 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 onreadyorquarantineddevices, 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 unclaimedreclaimingdevices, whichaddandremoverefuse anyway.- The warm pool converger (
src/core/warm-pool/converger.ts:687) passes ondevice.deleted; 1a makes the pass leave physical devices alone. WorkerLink(src/gateway/worker-link.ts:587) refreshes a worker's view on everydevice.*event. So a device enrolled or removed on a worker shows on the gateway after the worker'sdevice.enrolledordevice.deletedreaches it.- Claims are in memory. A daemon that stops during
addloses the claim; the record is either written or not. Nothing on the device needs undoing, because inspection only reads.
Rules in play
safety.mdrule 1 (as ADR 0022 amends it): the enrollment record is the only ownership.addreads;removesends nothing.safety.mdrule 8 (amended): a device is enrolled because an admin randevice add, never because it is plugged in.safety.mdrule 10:idis bounded before use.architecture.mdrules 2 and 10: the driver decides and words every refusal. The busy check is one helper used byaddandremove. The role check stays inrunDispatch.architecture.mdrule 12: every exit ofaddand of a quarantine retry releases its claim.events.mdrules 1, 3, 6, 8.testing.mdrules 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 noagain - (e2e) after
device add,status --jsonlists the deviceready,physical: true, with its class, and the events hold onedevice.enrolledwithreenrolled: falseand the app count - (e2e)
device add --jsonprints the registry ID, canonical ID, model, OS version, class, app count andreenrolled - (e2e)
device addnaming an enrolled device's alias keeps its registry ID, prints its line withagain, emitsdevice.enrolledwithreenrolled: true, andlist --devicesholds 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) answersDEVICE_NOT_ENROLLABLEwith that reason, the driver's message andidas typed, exits 12, and writes no record and nodevice.enrolled - (e2e) a client whose
hellorange ends at the previous protocol version is refused by the daemon withPROTOCOL_VERSION_UNSUPPORTED, so it never sendsdevice.addto a daemon that would answerUNKNOWN_REQUEST - (e2e)
device addof an ID no scripted device lists answersDEVICE_NOT_ENROLLABLEnot-connected - (e2e)
device add --platform iosnaming a Simlock simulator'sdriverDeviceId, anddevice add --platform androidnaming a Simlock emulator'sdriverDeviceIdand, separately, itsaddress, each answervirtual-device, and the driver log has noinspectPhysicalcall - (e2e)
device add --platform androidof an ID enrolled as iOS answersenrolled-on-other-platformwithcanonicalIdset, and leaves the iOS record as it was - (e2e)
device add <id>without--platformexits 2 withUSAGE, and the driver log has noinspectPhysicalcall - (e2e)
device addon a daemon with no Android driver answersNO_DRIVERfor--platform android - (e2e)
device addanddevice removefrom an agent session answerFORBIDDEN; the driver log has noinspectPhysicalcall and the record is unchanged - (e2e)
device remove <canonical ID>of areadydevice exits 0 and prints one line saying it is no longer enrolled and nothing on the device changed;list --devicesshows the recorddeleted; the events hold onedevice.deletedwithinitiator: "operator"andphysical: true; the driver log has no call after the remove - (e2e)
device removeof an ID with no record, and of a removed device, answersUNKNOWN_DEVICEand exits 12 - (e2e)
device addafterdevice removewrites a new record with a new registry ID andreenrolled: false, and the old record staysdeleted - (e2e) after an iPhone is enrolled,
lease --platform iosis granted a simulator, not the enrolled device - (e2e) after
device add,status --jsonfrom an operator session and from an agent session, andlist --devices, show the device's canonical ID asdriverDeviceId; a simulator's status entry has nodriverDeviceId - (e2e) on a gateway, after
device addon workerw1's own daemon, the gateway'slist --devicesshows the device onw1with itsdriverDeviceId, oncew1'sdevice.enrolledhas reached the gateway - (e2e)
POST /v1/deviceswith an operator token enrolls the device; with an agent token it is 403FORBIDDENand noinspectPhysicalcall is made; a body with aworkerfield, or any other unknown field, is 400BAD_REQUEST - (e2e)
DELETE /v1/devices/<canonical ID>with an operator token unenrolls the device; with an agent token it is 403FORBIDDENand the record staysready - (e2e) on a gateway,
device addanddevice removeeach answerUNSUPPORTED_IN_GATEWAY_MODEand exit 2, with a message saying to run the command on the worker the device is plugged into; no worker's driver log has aninspectPhysicalcall - (core) after
add, the record'senrolledAppsequal 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
addcalls of the same new device at once leave one non-deleted record and twodevice.enrolled, the second withreenrolled: true - (core) re-enrolling a
quarantinedphysical device makes itreadywith no quarantine fields and the same registry ID - (core) removing a
quarantinedphysical device makes itdeleted - (core)
addandremoveof aleaseddevice answerDEVICE_BUSYleasedand change nothing - (core)
addandremoveof areclaimingdevice, and of areadydevice whosereclaimclaim the test holds, answerDEVICE_BUSYreclaiming - (core) while an
addwaits on its second inspection, anotheraddand aremoveof that device answerDEVICE_BUSYbusy - (core) a virtual device's quarantine retry that finds its claim held makes no driver call, keeps its attempt count, and runs one
retryBackoffMslater - (core) a virtual device's quarantine retry holds the
reclaimclaim while its driverreclaimruns, and the claim is free after the retry succeeds and after the driver call throws - (core) the claim is free before
device.quarantine-recoveredis emitted and beforenotifyAvailability()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-recoveredreports that target'sreadyas 1 - (core)
doctor, run while a quarantined simulator's retry holds its claim, reports no live-drift finding for it, and reportsregistry-device-missingonce the fake no longer lists it, as without the claim - (core) an
addwhose second inspection is refused, or throws, leaves the record as it was, and aremoveright after it succeeds - (core) an
addwhose first inspection throws answersINTERNALand writes nothing - (core) an
addwhose second inspection names another canonical ID answersINTERNALand writes nothing, and aremoveright after it succeeds - (core) after a re-enrollment succeeds, a second
addof the device re-enrolls it again, and aremoveright after that succeeds - (registry) every
device.deletedthe registry emits carriesphysicalfrom its record:falsefromdeleteQuarantined,markDeviceMissing,endLeaseAndMarkDeviceMissingandtransitionDeviceon a virtual record,truefrom unenroll - (contract)
device.addanddevice.removeareadminin the role table ofsrc/contract/operations.test.ts - (contract) an
idof 201 characters, one with a space, and one starting with-are eachBAD_REQUEST - (types,
src/core/driver.test.ts) aPhysicalInspectionrefusal with reasonenrolled-on-other-platformfails to compile (@ts-expect-error) - (cli)
simlock devicewith an unknown subcommand,device addwith two IDs, anddevice remove --yes <id>exit 2 withUSAGE;device --helpprints both forms - (e2e)
device remove <id>from a non-interactive shell, with no--yes, unenrolls the device - (drivers) the real iOS and Android drivers'
inspectPhysicalrefuses an IDnot-connectedand 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.tspasses: with the fake driver,device addenrolls a scripted iPhone,status --jsonshows itreadyandphysical, and onedevice.enrolledis recorded.- With the fake driver, each scripted refusal answers
DEVICE_NOT_ENROLLABLEwith its reason, and no record is written. - An agent session's
device addanddevice remove, over the CLI and over HTTP, answerFORBIDDEN, and the driver is never asked. device removeof areadydevice leaves itdeleted, recordsdevice.deletedwithinitiator: "operator"andphysical: true, and the driver log shows no call for it.- An alias of an enrolled device re-enrolls the same record;
list --devicesnever shows two non-deleted records with one canonical ID. - On a gateway,
device addanddevice removeanswerUNSUPPORTED_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 abovemain's when this PR merges.simlock list --deviceson a gateway, andsimlock status --jsonfrom an agent session, show an enrolled device's canonical ID asdriverDeviceId.- The console's worker page marks a physical device "Physical".
- Both
EVENTS.mdfiles listdevice.enrolled, anddevice.deletedwithoperatorandphysical. docs/CLI.md,docs/HTTP-API.mdanddocs/CLIENT.mddocument both commands, both routes and both methods.
Out of scope
- The real iOS and Android inspection: tasks 4 and 5. Until then
device addagainst a real driver is refusednot-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 addanddevice remove: task 2 arms them and tests that meeting. - The
absentstate, 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
--workeroption and no gateway relay. An operator runsdevice addanddevice removeon the worker the device is plugged into.
Depends on
- docs(adr): a physical device is a second device type the core knows (ADR 0022) #435
- Core knows physical devices: the device union, its state machine, and every virtual-only path leaves them alone #436
Approval
- Approved for delivery
Written by an agent.
Activity
V3RON commentedon Oct 10, 2026
Spec updated: findings of the first spec check fixed (Technical spec, Tests, Done when, Depends on).
Written by an agent.
V3RON commentedon Oct 10, 2026
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.
github-actions commentedon Oct 11, 2026
Now
task:ready: approved, technical spec present, #435, #436 closed.feat(core): device add and device remove enrol and unenrol physical d…
docs: device add and device remove, both EVENTS.md, the status driver…
test: unit tests for enrollment, quarantine claim, registry, contract…
chore: protocol 24 across tests and docs; device.deleted carries phys…
test: a physical device's status entry keeps its canonical ID (#437)
fix: fallow findings for enrollment: ports as interfaces, smaller CLI…
fix: fallow findings for enrollment: no class-indexed type, smaller f…
2 remaining items
fix: read an enrolled device by its canonical ID under the claim; del…
test: kill surviving mutants of enrollment: messages, claims, parsing…
test: the other-platform refusal names the platform (#437)
fix: device remove answers the registry ID; unenrol and re-enrol go t…
chore: delete comments that say what, point elsewhere or tell history (…
test: a stopped simulator is refused unenrol; the guard is what refus…
fix: tests prove emit-after-commit; one place decides re-enrol busy; …
test: a client on the previous protocol version is refused at hello (#…
fix: tests prove the reason and the busy states their titles name; de…
docs: enrol refusal reasons and busy states are stated as the core de…
docs: DEVICE_NOT_ENROLLABLE message says why only; PhysicalEnrollment…
test: device.deleted payloads in startup-reconcile carry physical (#437)
chore(agents): CI failures reach fix runs; audit runs the comment rev…
feat(enrollment): simlock device add / remove enroll and unenroll phy…