Repository navigation
Description
Part of #428.
Scope
After this PR the core knows a device record may be physical. The registry loads and saves physical records in their own list in state.json, which an older Simlock never reads, and each type has its own state machine (ADR 0022 §1–2, without absent, which task 3 adds). Every path that boots, shuts down, destroys, counts or compares devices with a root now reads only virtual devices, so a physical record survives a daemon start, doctor --fix, doctor --purge-orphans, cleanup, the warm pool, nuke's shutdown and delete steps, the health monitor and the planner untouched. simlock status and simlock list --devices show physical records, with physical and class on each device entry and in a worker's view. A grant's device and the HTTP lease answer may have no mode for a physical device, so a gateway keeps a worker's physical device and answers for a lease on it. No user command creates a physical record yet: device add comes in the next task. This PR makes it safe for that record to exist.
In short
A physical record is a registry device with physical: true, e.g. an iPhone 15 Pro on iOS 18.4, class phone, with ID 00008110-001A2B3C4D5E. state.json keeps it in a new top-level list, physicalDevices; the devices list keeps only simulators and emulators. The virtual read is the registry's list of every device that is not physical. Paths that act on simulators and emulators use it. Paths shared by both types keep reading every device: the lease path, doctor's stalled-transition finding, status and list. In this PR the only way to get a physical record is to write it into physicalDevices by hand, which is what the tests do.
flowchart LR
R["registry: every device"] -->|"virtual read (new)"| V["startup, health monitor, doctor drift,<br/>purge-orphans, cleanup, warm pool,<br/>nuke, capacity, planner boot/evict"]
R -->|"every device"| S["lease grant (fits refuses physical *),<br/>release and expiry, stalled-transition,<br/>status and list (+physical, class *)"]
With a physical record written into state.json before the daemon starts (wording illustrative):
# before: the record loads as a simulator
$ simlock doctor --fix
registry-device-missing dev_p1 (ios) -> dev_p1 marked deleted
$ simlock lease --platform ios --device "iPhone 15 Pro" --os 18.4
Lease lse_1: dev_p1 ... -> a real device handed to a simulator request
# after
$ simlock doctor --fix
(no finding names dev_p1)
$ simlock status
Device dev_p1: ready, physical phone
$ simlock lease --platform ios --device "iPhone 15 Pro" --os 18.4
Lease lse_1: dev_v1 ... -> a new simulator; dev_p1 stays ready
Path, with a physical record in state.json |
Today (loads as a simulator) | After |
|---|---|---|
daemon start, record leased |
lease ended device-lost, device marked deleted |
lease and state kept |
daemon start, record reclaiming |
driver shutdown on it |
left reclaiming |
doctor --fix, record ready |
registry-device-missing, marked deleted |
no finding, stays ready |
doctor --fix, record reclaiming past its threshold |
stalled-transition, quarantined |
same: the finding is shared |
doctor --purge-orphans --yes |
not an orphan: untouched | unchanged |
cleanup, record ready and idle |
shut down | left alone |
warm pool off, record ready |
shut down | left alone |
| warm target for its model and OS | counted as warm | not counted; a simulator is made |
nuke --delete-devices --yes, record ready |
shut down, destroyed | left alone |
health monitor, record leased |
lease ended device-lost, marked deleted |
left alone |
lease --platform ios --device <its model> --os <its OS> |
granted the record | granted a simulator |
| device and running limits, RAM budget | counted | not counted |
| its lease expires | driver reclaim (erase) | lease ends, device reclaiming, no driver call (task 2 adds the reclaim) |
status, list --devices |
shown as a simulator | shown with physical: true and its class |
If this goes wrong, an operator would see, from the next task on, an enrolled device marked deleted after a restart, a doctor --fix, a cleanup or a nuke; a simulator request granted a real device; real devices counted against simulator limits; an older Simlock, started on the same state.json, treating an enrolled device as a simulator; or a worker shown incompatible by a gateway of the same release.
Technical spec
Modules touched
flowchart TD
CLI[cli: status line *] --> DSP[daemon/dispatcher: status, list *]
GW[gateway: warm-hit, worker-link *] --> CT[contract: schemas, protocol *]
HTTP[http: lease answer *] --> CT
DSP --> CT
UI[ui: test fixtures *] --> CT
DSP --> REG[core/registry + domain *]
LS[leasing: planner, reconciler,<br/>health monitor, release, modes *] --> REG
OPT[core optional: doctor, reaper,<br/>cleanup executor, warm pool, nuke *] --> REG
REQ[core required: reclaim, quarantine,<br/>capacity, lifecycle, startup, components *] --> REG
LS --> DRV[core/driver types *]
OPT --> DRV
REQ --> DRV
DRV --> IMPL[ios, android, both fakes: types only *]
sequenceDiagram
participant C as CLI or gateway
participant D as Daemon (worker)
participant R as Registry
C->>D: status.get / list.get
D->>R: snapshot.devices and snapshot.virtualDevices
R-->>D: records
D-->>C: devices with physical and class; capacity from virtual devices only
Note over C,D: a peer one protocol version apart is refused at hello: incompatible
Domain and registry:
src/core/domain.ts: the types of ADR 0022 §1.DeviceRecordbecomesVirtualDevice | PhysicalDevice, told apart byphysical.DeviceSpecbecomesVirtualSpec | PhysicalSpec. In memoryphysicalis always a boolean:falseon every virtual record and spec,trueon every physical one (ADR 0022 §1).PhysicalSpecis{ physical: true; platform; model; osVersion; class: "phone" | "tablet" }, with nomodeand noimageTag.PhysicalDeviceaddsenrolledApps: readonly string[], and has nomodeand noleaseIdentity. Every place that builds a virtual record or spec setsphysical: false, test fixtures included; the compiler finds them. (reclaimingSincecomes with task 3, the first task that entersreclaimingother than throughbeginRelease.)DeviceStatebecomesVirtualState | PhysicalState. In this taskPhysicalStateisready | leased | reclaiming | quarantined | deleted; task 3 addsabsent.transition(:411) checks a second table for a physical record:ready -> leased | deleted,leased -> reclaiming,reclaiming -> ready | quarantined,quarantined -> ready | deleted,deleted -> none. Anything else throwsIllegalTransition. The virtual table (legalTransitions, :380) is unchanged.sameSpec(:66) is false when one spec is physical and the other is not.fits(:131) is false for a physical spec. Every requirement in this task is virtual; task 2 adds physical requirements.transitionEnteredAt(:455) is unchanged. In this task a physical record entersreclaimingonly throughbeginRelease, which stampslastLeaseEndedAt, the field it already reads.mayBeGranted(:251) is true for a physical record:lease.identitydoes not apply.src/core/registry.ts:state.jsongains a top-level list,physicalDevices. Physical records are saved there and only there;devicesholds only virtual records. An older Simlock keeps an unknown top-level key as it found it (:829), so it never loads a physical record.physicalDevicesjoins the known top-level keys (:829), so it is not kept twice. A file with nophysicalDevicesloads with none. AphysicalDevicesthat is not a list fails the load, asdevicesdoes (:825).- Rollback: an older Simlock still loads a lease on a physical device and keeps it. Its startup reconciler skips a lease whose device it does not hold (
src/leasing/lease-reconciler.ts:44). But it cannot end that lease. Its expiry,release,release --allandnukeall go throughbeginRelease, which throwsUnknownDeviceErrorwhen the lease's device is not indevices(src/core/registry.ts:659-663). ADR 0022 §1 said the older daemon ends such a lease; docs(adr): ADR 0022 amendments from the #428 spec check #443 corrects it. So leases on physical devices must be released before a rollback.docs/internal/KNOWN-PITFALLS.mdgains an entry that says this: an older Simlock cannot end a lease on a physical device, so release those leases before rolling back. - In memory the two lists are one.
RegistrySnapshot.devices(:67) lists the records ofdevices, then those ofphysicalDevices. The snapshot gainsvirtualDevices: readonly VirtualDevice[]: every record that is not physical, in snapshot order. It is the virtual read. No caller writes the filter itself. parseDevice(:1061), for a record indevices: it loads as virtual, withphysical: falseon the record and its spec when the file has none, as every record today. It fails the load when the record or its spec carries aphysicalother thanfalse, or its spec carries aclass.parseDevice, for a record inphysicalDevices: it needsphysical: trueon the record and the spec, a specclassofphoneortablet, no specmodeorimageTag, no recordmodeorleaseIdentity, a physical state, andenrolledAppsas a list of strings.- A record that breaks any of these rules fails the load, like any corrupt record.
deviceRecordKeys(:893) gainsphysicalandenrolledApps, so they are not kept twice as unknown fields. - A virtual record saves into
devicesas today, with nophysicalkey on the record or its spec. A physical record saves intophysicalDeviceswithphysical: trueon both. #commitMissing(:573), the one place a device is written as missing, throws for a physical record and writes nothing. SomarkDeviceMissing(:526) andendLeaseAndMarkDeviceMissing(:549) can never unenroll one.
Driver types:
src/core/driver.tsgainsVirtualDriverDevice = DriverDevice & { physical: false }(ADR 0022 §5).provisiontakes aVirtualSpec, andresolveSpecreturns one.makeReady,reclaim,shutdown,destroyanddestroyLegacytake aVirtualDriverDevice. So a driver device withphysical: truedoes not compile there.estimatekeeps taking anyDeviceSpec: it prices a physical reclaim for the stall rule.reclaimwidens to physical devices in task 2, which also declaresPhysicalDriverDevice.- The three private
toDriverDevicecopies (src/core/reclaim-coordinator.ts:285,src/core/managed-device-lifecycle.ts:385,src/core/quarantine-coordinator.ts:299) take aVirtualDeviceand return aVirtualDriverDevice. Task 2 merges them into one. src/drivers/ios,src/drivers/android,src/core/fake-driver.ts,e2e/fake-driver/fake-driver.ts: type changes only. No behaviour changes.
Paths that switch to the virtual read:
- Startup:
StartupConverger#actionableDevices(src/core/startup-converger.ts:99) is the one startup filter, so a physical record leftreclaiminggets noshutdown.ReclaimCoordinator.recoverInterrupted(src/core/reclaim-coordinator.ts:117) is called only from the converger'sreclaiminglist (:82, throughsrc/core/create-core.ts:329-330). It adds no filter of its own: its re-reads look the ID up in the virtual read only so the compiler knows the record is virtual.LeaseReconciler.run(src/leasing/lease-reconciler.ts:39) skips a lease whose device is physical: the lease and the device keep their state (ADR 0022 §7). - Health monitor:
#runTick(src/leasing/lease-health-monitor.ts:126). - Doctor (
src/core/doctor.ts): the drift loop (:253) compares only virtual devices with root reality. So a physical record never getsregistry-device-missing,foreign-state-change,foreign-provenance-changeorlegacy-device. The stalled-transition finding (:271) still checks every device. The orphan comparison (:246) keeps every record's key: an ID a physical record holds is never purged as an orphan. - Cleanup: the reaper's view (
src/core/reaper.ts:191) andCleanupExecutor.execute(src/core/cleanup-executor.ts:35). - Warm pool: every registry read in
src/core/warm-pool/converger.ts(:205, :285, :364, :443, :559, :597, :628). - Capacity:
create-core.ts(:202, :215) and the status figures insrc/daemon/dispatcher.ts:355build from the virtual read. So limits, the RAM budget,capacity.changed, and status'used,runningandwarmnever count a physical record. - Planner (
src/leasing/acquisition-planner.ts): the on-its-way check (:99), the shutdown boot (:105), every capacity input (:123, :143, :179) and eviction candidates (:190). The ready grant (:90) keeps every device;fitsrefuses a physical one. ManagedDeviceLifecycle(src/core/managed-device-lifecycle.ts):bootForLease,bootWarm,readyProvisionedForLease,shutdown,destroyandrecoverLeasedtake aVirtualDevice, and their re-reads (:349, :371) use the virtual read.- Nuke:
NukeService(src/core/nuke-service.ts:80, :95, :118) shuts down and deletes only virtual devices. Its lease step,LeaseReleaseCoordinator.releaseAllDuringMaintenance(src/leasing/lease-release-coordinator.ts:209), is not changed here. Nuke ending a physical lease, and the reclaim that follows, are task 2's: they are tested there with the real physical reclaim. ComponentInstaller#simlockDevices(src/core/component-installer.ts:525): a physical record never counts as using a runtime.QuarantineCoordinator.restore(src/core/quarantine-coordinator.ts:142).
Shared paths, with the interim for this task:
ReclaimCoordinator.reclaim(src/core/reclaim-coordinator.ts:77) makes no driver call for a physical record. The device staysreclaiming, aged fromlastLeaseEndedAt. Until task 2 adds the physical reclaim, doctor's stalled-transition rule is what moves it on.QuarantineCoordinatorarms no retry for a physical record: not fromenter(:89),enterFromStalledTransition(:122) orrestore(:142). The record staysquarantined. Itsdevice.quarantinedevent and its record still carry the backoff'snextRetryAt; no timer acts on it until task 2 adds the retry and the give-up. Neither interim can be reached by a user before task 2: a physical lease cannot be granted until then.LeaseAcquisitionCoordinator.servesDefaultMode(src/leasing/lease-acquisition-coordinator.ts:201) is false for a physical spec.#notifyReclaimWait(src/leasing/lease-acquisition-coordinator.ts:991) is unchanged: it usessameSpec, which never matches a physical record for a virtual request. So a reclaiming physical record never gives a virtual waiter areclaimingprogress stage.
Status, list and the gateway:
Dispatcher#decorateDevice(src/daemon/dispatcher.ts:843) setsphysicalon every device andclasson a physical one. A physical device's entry has nomode.ui/: the test fixtures typed from the worker view (ui/src/views/workers-model.ts:13) gainphysical: false, on the device and its spec:attention.test.tsx,workers.test.tsx,leases.test.tsx,worker-detail.test.tsx. The views that read a device'smode(worker-detail.tsx:151,leases.tsx:181) keep compiling withmodeoptional. So the console lane runs (toolchain.md). Marking physical devices in the console is task 1b's. Until then a physical record shows as any device does: a row in the worker page's Devices table with an empty Mode cell (worker-detail.tsx:151), a device in the Workers view's device total (ui/src/views/workers-model.ts:85), and, when quarantined or stalled, an attention item (ui/src/views/attention-model.ts:60). The lease page's Mode fact (leases.tsx:325) reads the HTTP lease answer'smode, so it is empty for a lease on a physical device; the Leases table's Mode cell (leases.tsx:181) shows "—".src/cli/index.tsformatStatus(:2381): a physical device's line readsDevice <id>[ on <worker>]: <state>, physical <class>with today's markers. A virtual device's line is unchanged.src/gateway/routing/stages/warm-hit.ts: the spec it builds from a view device carries the entry'sphysical, sofitsrefuses a physical device and it is never a warm hit.
Contract and event changes
statusDeviceSchemaanddeviceRecordSchema(src/contract/schemas.ts:145, :82) gainphysical: boolean, always present, andclass(phoneortablet), present only on a physical device.modestays required whenphysicalisfalseand is absent when it istrue. The worker view's devices (src/contract/worker-view.ts:70) arestatusDeviceSchema, so a gateway reads all three from its workers.enrolledAppsstays off the wire, likedeferredReclaimLeaseId:#decorateDevicespreads the whole record (src/daemon/dispatcher.ts:850), and no output schema declares the field.deviceSpecSchema(src/contract/schemas.ts:72) gainsphysical: boolean: responses pass the in-memory spec through, so a grant's, a status entry's and a list entry's spec carry it.grantedDeviceSchema(src/contract/schemas.ts:198) gainsphysical: boolean, always present, andclass, present only on a physical device, as the status entry has them. Itsmode(:212) may be absent only whenphysicalistrue. No physical grant exists before task 2, but the gateway parses a worker'slist.getdevices with this schema (src/gateway/worker-link.ts:114,:116) and leaves out a device that does not parse. Without this change it would leave out a physical device, and itsGET /v1/leases/{id}for a lease on one would answerUNKNOWN_LEASE. Task 2 only references this shape.- The HTTP lease answer:
HttpLeaseDevice.mode(src/http/tracker.ts:405) andLeasePayload.mode(:47) become optional.buildLeasePayload(:86) leavesmodeout when the device has none, which the contract allows only for a physical device. So a worker's registry record still serves as anHttpRegistryDevice(src/http/app.ts:50) when it is aPhysicalDevice, andGET /v1/leases/{id}(src/http/app.ts:406-415) answers for a lease on a physical device with nomode, on a worker and on a gateway. src/contract/protocol.ts: protocol +1 at merge. Its comment gains the reason: a gateway readsphysicalfrom every worker's devices, so a worker on the previous version isincompatible.- Events:
device.provisioned's spec gainsphysical: false(additive, events rule 6); both EVENTS.md files say so. Searchedsrc/bus/index.ts: it is the only event with a spec (:137), and a physical record never emits it. Every event this task can emit for a physical record (lease.expired,lease.released,device.quarantined,doctor.reconciled) keeps its payload.
Other code on the same state
The state is the device records and leases in state.json, and the timers and claims that act on them. Found by searching src/ for snapshot.devices, .devices and the registry's device-writing methods.
LeaseExpirySchedulerandLeaseReconciler: at startup the reconciler skips a physical lease, then the expiry timers are restored. A deadline that passed while the daemon was down expires the lease right after start. Expiry wins: it is the shared lease path.LeaseHealthMonitorandLeaseExpiryScheduler: the monitor never reads a physical record, so only expiry or a release ends a physical lease.Doctor --fixand a release in flight: both run under the serialized decision.#fixStalledTransitionre-reads the state first. A release's background reclaim holds a claim, whichisStalledTransitionhonours, but for a physical record that claim lasts only for the no-op reclaim. Whichever commits first wins; the other finds the state moved and does nothing.Doctor --fixandStartupConverger: the converger never reads a physical record, so only doctor moves a stuck physical reclaim.QuarantineCoordinatorandDoctor: doctor's fix callsenterFromStalledTransition; the coordinator commitsquarantinedand arms nothing. No other path moves the record.NukeServiceand every lease release: nuke's shutdown and delete steps leave physical records alone. Its lease step, arelease,release --alland expiry are the shared lease path, unchanged here; task 2 tests them on a physical lease.- Warm pool converger and planner: neither boots, shuts down or evicts a physical record. The planner's ready grant reads every device, and
fitsrefuses a physical one for every request in this task. CapacityCoordinatorandCapacityObserver: both read only the virtual read, so a physical record's state change moves no figure. A registry commit still wakes the observer, which then finds nothing changed.DeviceOperationClaims: nothing in this task claims a physical record except a release's no-op reclaim.ComponentInstaller: reads the virtual read only.Dispatcherstatus and list,src/http/app.ts:820(a lease's device),src/leasing/lease-lifecycle.ts:94(the granted device): read every device, unchanged.- Gateway:
aggregate.ts:89anddispatcher.ts:381copy view devices withworkerId, sophysicalandclasspass through.worker-link.ts:114parses a worker's devices withgrantedDeviceSchema: it keeps a physical device, withphysicalandclassand nomode(the grant shape above), and its spec'sphysicalis always sent by a worker on this protocol. The gateway's HTTP lease answer is built from these devices, so it answers for a lease on a physical device.routing/view-state.ts:12keys a refusal memo on the view's devices: a physical record's state change ends the memo early, which costs one more probe.warm-hitis changed above. - The console (
ui/) reads the worker view's devices and the HTTP lease answer. Its code does not change here, only its fixtures. It shows a physical record as it shows any device, as said above, until task 1b marks physical devices. Nuke.run(src/core/nuke.ts:21,:26): reads every device, to report which devices the nuke deleted. Nuke never deletes a physical record, so it never reports one.ReclaimCoordinator#unleasedDevice(src/core/reclaim-coordinator.ts:249): reads the virtual read. It looks for ashutdowndevice, a state a physical record never has, and its result goes todestroy, which takes a virtual device.QuarantineCoordinator#retry(src/core/quarantine-coordinator.ts:175): its re-read (:181) reads the virtual read. Only a retry timer calls it, and this task arms none for a physical record. Its result goes to the driver'sreclaim, which takes a virtual device in this task.- Doctor's fix re-reads,
#fixMissingDevice(src/core/doctor.ts:701-706) and#fixForeignStateChange(:749-756): read every device, unchanged. They cannot reach a physical record: only a drift finding leads to them, and the drift loop raises none for a physical record.
Rules in play
architecture.mdrules 1–3: the core learns that a device may be physical (ADR 0022 §10). It still never learns what a UDID or serial means.architecture.mdrule 6: one transition function, two tables, no platform-specific state.architecture.mdrule 10: one virtual read, onefits, one#commitMissingguard.architecture.mdrule 12: every interim exit above leaves the record in one named state.architecture.mdrule 15: the optional components read the core's virtual read; nothing required reads them.safety.mdrules 1, 7, 8 as amended by ADR 0022: a physical record is never shut down, destroyed, booted or marked missing.testing.mdrules 1–4: each "left alone" test must fail when its path reads every device.toolchain.md"Contract shapes" and "Published surfaces": the schema change reruns MCPtools/list, HTTP and CLI help tests.
Tests
Main seam: withDaemon with the out-of-process fake driver, with state.json written into env.home before env.startDaemon(); the physical records go in its physicalDevices list. Model on e2e/startup-reconcile.test.ts (restart, driver log), e2e/doctor-drift.test.ts, e2e/capacity-cleanup-nuke.test.ts and, for the RAM budget, e2e/ram-budget-by-mode.test.ts. The seeded records: an iOS iPhone 15 Pro on 18.4, class phone, in ready, in leased (with a lease owned by the env's agent id) and in reclaiming (with lastLeaseEndedAt a day old). None is in the fake's scripted reality unless a line says so. "No driver call names it" means the fake driver's call log has no call whose argument carries that record's driverDeviceId. Unit seams: src/core/domain.test.ts, src/core/registry.test.ts, src/core/quarantine-coordinator.test.ts, src/core/reclaim-coordinator.test.ts, src/core/component-installer.test.ts, src/core/driver.test.ts, src/contract/schemas.test.ts, src/contract/worker-view.test.ts, src/gateway/routing/stages/warm-hit.test.ts, src/gateway/service.test.ts, src/http/app.test.ts, src/leasing/lease-acquisition-coordinator.test.ts, src/cli/index.test.ts, and the console's ui/src/views/worker-detail.test.tsx, workers.test.tsx, attention.test.tsx and leases.test.tsx.
- (e2e) a daemon started, then restarted, with a ready, a leased and a reclaiming physical record keeps each in its state; the lease renews for its owner; no driver call names any of them
- (e2e) a leased physical record whose lease deadline passed before the daemon started: right after start the lease ends with
lease.expired, the record isreclaiming, and nolease.releasedwith reasondevice-lostand no driver call names it - (e2e) with
health.probeIntervalMs200 andstableObservations2, a leased physical record is still leased after two seconds, its lease renews, and nolease.releasedordevice.deletednames it - (e2e)
doctor --fixreports no finding naming the ready or the leased physical record and leaves both in their state - (e2e)
doctor --fixreportsstalled-transitionfor the reclaiming physical record and moves it toquarantinedwithdevice.quarantined; afterwarmPool.quarantine.retryBackoffMshas passed, no driver call names it - (e2e) with a scripted orphan simulator and a scripted root device whose ID is the ready physical record's
driverDeviceId,doctor --purge-orphans --yesdestroys the orphan, reports no orphan for that ID, and nodestroycall names it - (e2e) with idle thresholds of one second and the ready physical record's
lastLeaseEndedAtan hour old,cleanup --dry-runproposes nothing for it, andcleanupleaves itreadywith noshutdowncall - (e2e) with
warmPool.enabledfalse, the ready physical record is stillreadyafter the pool's pass, with noshutdowncall - (e2e) a warm target of one
iPhone 15 Proon18.4provisions a simulator; the target's report counts that simulator, not the physical record; itsdevice.provisionedspec carriesphysical: false - (e2e, model
e2e/warm-targets.test.ts) withlimits.ios.maxDevices1 and a ready physical record, a warm target of oneiPhone 15 Proon18.4provisions a simulator; were the physical record counted, the device limit would refuse it - (e2e, model
e2e/warm-targets.test.ts) withlimits.ios.maxRunning1, a ready physical record and a shut-downiPhone 15 Prosimulator on18.4, a warm target of oneiPhone 15 Proon18.4boots that simulator; were the physical record counted, the running limit would refuse the boot - (e2e) with
limits.ios.maxDevices1 andmaxRunning1 and a ready physical record,lease --platform ios --no-waitis granted a new simulator, notNO_CAPACITY; the physical record staysready, and status' iOSused,runningandwarmcount only the simulator - (e2e) with
limits.ios.maxDevices1, an idle readyiPhone 16simulator and a ready physical record whoselastLeaseEndedAtis older than the simulator's,lease --platform ios --device "iPhone 15 Pro"evicts the simulator (adestroycall names it) and is granted a new simulator; the physical record staysready, and no driver call names it - (e2e) with a ready and a leased physical record, the
capacity.changedemitted at start and after a simulator lease count only the simulator in iOSrunningandwarm; under theresourcecapacity strategy itsramBudget.usedBytesand status' match the simulator's alone - (e2e)
lease --platform ios --device "iPhone 15 Pro" --os 18.4andlease --platform ios --class phoneare each granted a simulator while the physical record of that model and OS isready - (e2e) with a ready and a reclaiming physical record and no physical lease,
nuke --delete-devices --yesdestroys a simulator as today, keeps both physical records in their states, and noshutdownordestroycall names them - (e2e) a physical lease whose deadline passes while the daemon runs ends with
lease.expired; its record isreclaimingwithlastLeaseEndedAtset, and no driver call names it - (e2e)
status --jsonandlist --devicesshow a physical record withphysical: true,class: "phone"and nomode, and a simulator withphysical: false, itsmodeand noclass - (e2e) with a physical record seeded with two enrolled apps, the
status --jsonandlist --devicesanswers and a gateway'sGET /v1/workerscarry noenrolledAppson it - (e2e, model
e2e/gateway-fleet.test.ts) a gateway lists a worker's physical record withphysical,classandworkerId, and a gatewaylease --platform ios --device "iPhone 15 Pro" --os 18.4is granted a simulator on that worker - (domain) every transition in the physical table is legal, and
ready -> shutdown,reclaiming -> shutdown,shutdown -> readyand any move intoprovisioningthrowIllegalTransitionfor a physical record; the virtual table is unchanged - (domain)
sameSpecis false for a physical and a virtual spec with the same platform, model and OS - (domain)
fitsis false for a physical spec, for a model requirement and for a class requirement it would otherwise meet - (domain)
transitionEnteredAtof a reclaiming physical record is itslastLeaseEndedAt;mayBeGrantedis true for a physical record - (types,
src/core/driver.test.ts) each of these fails to compile (@ts-expect-error): a value typedDriverDevice & { physical: true }passed tomakeReady,reclaim,shutdown,destroyordestroyLegacy(src/core/driver.ts:403); aPhysicalSpecpassed toprovision(:319);resolveSpec's result (:274) assigned to aPhysicalSpec; aPhysicalDevicepassed toManagedDeviceLifecycle'sbootForLease,bootWarm,readyProvisionedForLease,shutdown,destroyorrecoverLeased. The same calls with aVirtualDriverDevice, aVirtualSpecand aVirtualDevicecompile, andresolveSpec's result passes toprovisionas it is - (registry) a record in
deviceswith nophysicalloads withphysical: falseon the record and its spec, and is written back with nophysicalkey on either - (registry) a record in
devicesfails the load when the record or its spec hasphysical: trueor a non-booleanphysical, or its spec has aclass - (registry) a physical record with its class, enrolled apps and address survives a save and a load unchanged
- (registry) a record in
physicalDevicesfails the load whenphysicalis missing or nottrueon the record or its spec, its class is missing orwatch, its state isshutdownorprovisioning, its spec has amodeor animageTag, the record has amodeor aleaseIdentity, or itsenrolledAppsis not a list of strings - (registry) a file with no
physicalDevicesloads with no physical record; one whosephysicalDevicesis a string fails the load - (registry) after a virtual and a physical record are saved, the file's
deviceslist holds only the virtual record andphysicalDevicesonly the physical one; a reader ofdevicesalone, as an older Simlock, finds no physical record - (registry)
snapshot.virtualDeviceslists every virtual record in snapshot order and no physical one - (registry)
markDeviceMissingandendLeaseAndMarkDeviceMissingon a physical record throw and leavestate.jsonunchanged - (registry)
beginReleaseof a physical lease moves the record toreclaimingand stampslastLeaseEndedAtwith the clock's time - (reclaim)
ReclaimCoordinator.reclaimof a released physical lease calls no driver method and leaves the recordreclaiming - (quarantine)
enterFromStalledTransitionof a physical record commitsquarantinedand arms no timer;restorearms none for a quarantined physical record - (components) a physical record on OS
18.4does not count as a device using the18.4runtime, so its removal is not refused for it - (leasing)
servesDefaultModeis false for a physical spec on each platform - (leasing) a waiter for
iPhone 15 Proon18.4that queues while the only reclaiming device of that model and OS is a physical record gets noreclaimingprogress stage - (contract)
statusDeviceSchemaanddeviceRecordSchemaaccept a physical device withclassand nomode, and a virtual one withmode; they refuse a device withoutphysical, a spec withoutphysical, a virtual device withoutmode, a physical device with amode, a physical device withoutclass, and a virtual device with aclass; the worker view keepsphysicalandclass, and refuses the same devices - (contract)
grantedDeviceSchemaaccepts a physical device withclassand nomode, and keepsphysicalandclass; it refuses a device withoutphysicaland a virtual device withoutmode - (http)
GET /v1/leases/{id}for a lease on a physical device answers the lease with nomode, on a worker and on a gateway; for a lease on a simulator it keeps themode - (gateway) a worker's physical device with no
modeis kept in the grant shape beside its view, withphysicalandclass, so the gateway'sGET /v1/leases/{id}for a lease on it does not answerUNKNOWN_LEASE - (gateway)
warm-hitscores 0 for a worker whose only ready device that fits the request is physical - (cli) the human status line of a physical device reads
Device <id>: ready, physical phone; a simulator's line is unchanged - (console) with a physical device in a worker's view, the worker page's Devices table lists it with an empty Mode cell, the Workers view's device total counts it, and a quarantined physical device is an attention item; a lease on it shows an empty Mode fact on its lease page and "—" in the Leases table's Mode cell
Done when
- With a ready, a leased and a reclaiming physical record seeded in
state.json: a daemon start, a restart,doctor --fix,doctor --purge-orphans --yes,cleanupand a warm-pool pass leave the ready and leased records and the lease in place, and the fake driver's call log names none of them. nuke --delete-devices --yesleaves a ready physical recordready, and the fake driver's call log names it in noshutdownordestroy.- An eviction for a simulator request destroys a simulator, never a physical record.
doctor --fixmoves the reclaiming physical record, past its threshold, toquarantined.simlock lease --platform ios --device "iPhone 15 Pro" --os 18.4is granted a simulator while a ready physical record of that model and OS exists.simlock status --jsonandsimlock list --devicesshow the physical record withphysical: trueandclass, and status' capacity figures andcapacity.changeddo not count it.- A gateway with that worker lists the record with
physicalandclass. GET /v1/leases/{id}for a lease on a physical record answers the lease with nomode, on a worker and on a gateway, neverUNKNOWN_LEASE.docs/internal/KNOWN-PITFALLS.mdsays an older Simlock cannot end a lease on a physical device, and that those leases must be released before a rollback.- A
state.jsonwritten before this PR loads, and every record in it stays virtual. - A
state.jsonwritten by this PR holds physical records only inphysicalDevices, never indevices. PROTOCOL_VERSION_RANGE(src/contract/protocol.ts:47) is one abovemain's when this PR merges, and the comment above it names this change as the reason.
Out of scope
- Enrolling and removing physical devices, their events, and marking them in the console (1b).
lease --physical, the physical reclaim (uninstall), its quarantine retries and the give-up that unenrolls (2).- Nuke ending a physical lease and the reclaim after it (2). This task does not change nuke's lease step.
reclaimingSince(3).- The
absentstate and the physical-device watcher (3). - The physical handlers in the iOS and Android drivers (4, 5), and the driver methods only they implement:
inspectPhysical(declared with its first caller in 1b) andlistPresentPhysical(declared with its first caller in 2).PhysicalDriverDeviceis declared in 2, withreclaim's widening. - Routing physical requests through a gateway (6).
Depends on
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.
V3RON commentedon Oct 10, 2026
Spec updated: Depends on drops #147 and #381 (maintainer: physical devices go first); those two now wait for #442.
Written by an agent.
github-actions commentedon Oct 10, 2026
Now
task:ready: approved, technical spec present, #435 closed.feat(core): the core knows physical devices; every virtual-only path …
test: kill the mutants the first push left alive in the physical-devi…
test: kill the last mutants a registry and doctor change left alive (#…
test: physical transition refuses a state outside its table, and retr…
docs: quarantine, capacity, mode and physical-device comments and doc…
fix: index.ts for cleanup and routing, tests prove their titles, comm…
docs: comments name the code as it is: physical state check, virtualD…
chore(agents): comments say why or go; a Haiku comment review; increm…
chore(agents): implementer in phases, restricted tools, raw hand-offs…
V3RON commentedon Oct 11, 2026
Handoff
Done: PR #444 built (20/20 spec tests green); review passed in round 4 at
c1b0bcd; notes posted; PR marked ready.Not done: merge. CI Quality fails on e2e/doctor-drift (#407) on every run of this branch, including the gate's re-run.
Findings: root cause found (see the Handoff on PR #444): doctor's
--fixregistry writes skip theSerializedDecisionlock, so a warm-pool commit can undo deviceC'sdeletedwrite. Planned fix: adecisionsoption onDoctor, used by#fixMissingDeviceand#fixForeignStateChange. A red unit test and a clean merge of main (ed4da6b, unpushed) sit in worktree.claude/worktrees/agent-acf1d525f17248342, branchfix436-ci.Blocked on: the session's token budget ran out. Next: one implementer fix run applying the plan and pushing, an
after-passreview on the new commit, then.agents/scripts/merge-pr.sh 444. Claim still held; release it if another session takes over.Written by an agent.
chore(agents): CI failures reach fix runs; audit runs the comment rev…
test: #436 interim tests follow #438: an expired physical lease is re…
feat(leasing): lease --physical grants, queues and reclaims enrolled …