Part of #70. See ADR 0021.
Purpose
Add a lease-identity policy that gives each lease a new simulator identity.
The current reusable policy can erase and grant the same UDID again. Keep that policy for existing installations.
Scope
After this task, an operator can configure iOS so that every lease gets a simulator that Simlock created for that lease and deletes afterwards. No UDID is granted twice. A deletion that fails parks the device in quarantine instead of returning it to the pool.
This task does not change where simulators live. It works in the current owned device set and works unchanged in the default set once #70's iOS root decision lands.
Configuration
Store the policy in daemon configuration under lease.identity. A lease request cannot change it.
interface LeaseIdentityConfig {
ios: "reusable" | "fresh";
android: "reusable" | "fresh";
}
Both keys default to "reusable". The core reads the value for a device's own platform by key lookup. The core never branches on platform. fresh is accepted for Android, but iOS is the intended use.
Store the resolved policy on the device record when the device is created. A device keeps the policy it was created under after configuration changes.
Fresh identity lifecycle
No new device state. Every step uses a transition that exists today.
- Create a simulator for one lease. Boot and check it.
- Grant it at most once.
- The lease ends through release, expiry, or recovery-driven termination. The device enters
reclaiming as today.
- For a fresh device, reclaim is a driver shutdown followed by a commit to
shutdown. No erase.
- Destroy takes it from
shutdown to deleted. Capacity is freed at deleted.
A fresh device that has ended a lease is spent. lastLeaseEndedAt is already stamped on every release, so spent means leaseIdentity is "fresh" and lastLeaseEndedAt is set. One predicate answers whether a device may be granted. The acquisition planner applies it to the ready scan and the shutdown scan, so a spent device is never booted for a new lease in the window between the shutdown commit and the delete.
If the delete fails, the device enters the existing quarantined state. Quarantine retries the delete, not an erase. A successful retry reaches deleted. A spent device never returns to ready.
Startup convergence finishes the job for any spent fresh device it finds in reclaiming or shutdown: shutdown if needed, then delete.
Technical spec
Modules touched
src/core/config.ts — the lease.identity block, its defaults, and its validator.
src/core/domain.ts — DeviceRecord.leaseIdentity; the one predicate that answers whether a device may be granted.
src/core/registry.ts — stamp leaseIdentity in registerDevice; read it in parseDevice, defaulting a record written without it to "reusable".
src/core/acquisition-planner.ts — apply the predicate to the ready scan and the shutdown scan.
src/core/warm-pool-coordinator.ts — for a fresh device, reclaim is shutdown, commit shutdown, then destroy. A failed destroy hands off to quarantine the way a failed purge does.
src/core/quarantine-coordinator.ts — retry a fresh device's delete instead of its reclaim. Success reaches deleted.
src/core/startup-converger.ts — finish the delete of every spent fresh device found at start.
src/contract/schemas.ts — the config schema for the new block.
docs/CONFIGURATION.md, docs/EVENTS.md, docs/internal/EVENTS.md.
Contract and event changes
config.get returns the new lease.identity block. No new daemon operation, no request field, and the grant is unchanged.
device.purge-failed — attemptedStrategy gains "delete", emitted when a fresh device's delete fails. This widens a published vocabulary. Record it in both EVENTS.md cuts, and say there that a consumer must tolerate a strategy it does not know.
device.deleted — emitted with initiator lease-end when a fresh device's delete completes. Payload unchanged.
device.reclaimed — not emitted for a fresh device. Nothing was reclaimed.
device.quarantined, device.quarantine-abandoned, device.quarantine-stranded — unchanged, and now also reachable from a failed delete. Update their rows to say so.
device.quarantine-recovered — unchanged, and still means a device rejoined the pool. A fresh device whose retried delete succeeds emits device.deleted instead.
Rules in play
architecture.md 1, 2, 3 — the core reads lease.identity by platform key and never branches on platform. No iOS concept enters the core.
architecture.md 6 — no state is added. The state machine already has every transition this task uses.
architecture.md 10 — one grantability predicate, called by the acquisition planner and by quarantine. Two copies would diverge.
safety.md — destruction stays registry-only. A spent device is still a registry record, which is what authorises deleting it.
events.md 6, 8 — the vocabulary widening is documented in the same change.
testing.md — every test title below is a claim its body must prove.
Tests
- Two sequential fresh leases for one shape return different driver device ids.
- A released fresh device reaches
deleted and its driver device is destroyed.
- An expired fresh lease deletes its device.
- A recovery-driven termination deletes its device.
- A fresh device is never erased.
- The acquisition planner never selects a spent fresh device from the ready scan or from the shutdown scan.
- A device created under
fresh is still deleted after configuration changes to reusable.
- A device created under
reusable still reclaims and rejoins the pool.
- A daemon that crashes while a fresh device is
reclaiming deletes the device on the next start.
- A daemon that crashes after the
shutdown commit and before the delete deletes the device on the next start.
- A spent fresh device counts against managed-device capacity until
deleted.
- A failed delete leaves the device
quarantined and ungrantable, and emits device.purge-failed with strategy delete.
- Quarantine retries a fresh device's delete rather than an erase, and a successful retry reaches
deleted, never ready.
- A registry written before
leaseIdentity existed loads with every device reusable.
- A
lease.identity value other than reusable or fresh fails config validation naming the key.
Timeouts
None added. Measured on an M-series Mac with iOS 26.4 and a shut-down device: create 0.3 s, delete 0.3 s, boot to usable about 29 s, the same boot an erased device pays. The shared command timeout covers the delete. Under fresh, no device waits warm after a release, so every lease pays that boot. docs/CONFIGURATION.md says so next to the knob.
Completion conditions
- Two sequential fresh leases for one shape return different UDIDs.
- Simlock deletes a fresh identity after release, expiry, or recovery-driven termination and never grants it again.
- A device created before its request receives at most one lease.
- A spent device continues to use managed-device capacity until it is deleted.
- A failed delete leaves an ungrantable quarantine record.
- Restart tests cover create, grant, release, expiry, reclaiming, shutdown-before-delete, and delete.
- The reusable policy keeps its current behaviour.
- Both EVENTS.md cuts carry the
device.purge-failed widening and the new device.deleted initiator.
docs/CONFIGURATION.md documents the knob and the boot cost.
Out of scope
Depends on
None.
Approval
Written by an agent.
Part of #70. See ADR 0021.
Purpose
Add a lease-identity policy that gives each lease a new simulator identity.
The current reusable policy can erase and grant the same UDID again. Keep that policy for existing installations.
Scope
After this task, an operator can configure iOS so that every lease gets a simulator that Simlock created for that lease and deletes afterwards. No UDID is granted twice. A deletion that fails parks the device in quarantine instead of returning it to the pool.
This task does not change where simulators live. It works in the current owned device set and works unchanged in the default set once #70's iOS root decision lands.
Configuration
Store the policy in daemon configuration under
lease.identity. A lease request cannot change it.Both keys default to
"reusable". The core reads the value for a device's own platform by key lookup. The core never branches on platform.freshis accepted for Android, but iOS is the intended use.Store the resolved policy on the device record when the device is created. A device keeps the policy it was created under after configuration changes.
Fresh identity lifecycle
No new device state. Every step uses a transition that exists today.
reclaimingas today.shutdown. No erase.shutdowntodeleted. Capacity is freed atdeleted.A fresh device that has ended a lease is spent.
lastLeaseEndedAtis already stamped on every release, so spent meansleaseIdentityis"fresh"andlastLeaseEndedAtis set. One predicate answers whether a device may be granted. The acquisition planner applies it to the ready scan and the shutdown scan, so a spent device is never booted for a new lease in the window between theshutdowncommit and the delete.If the delete fails, the device enters the existing
quarantinedstate. Quarantine retries the delete, not an erase. A successful retry reachesdeleted. A spent device never returns toready.Startup convergence finishes the job for any spent fresh device it finds in
reclaimingorshutdown: shutdown if needed, then delete.Technical spec
Modules touched
src/core/config.ts— thelease.identityblock, its defaults, and its validator.src/core/domain.ts—DeviceRecord.leaseIdentity; the one predicate that answers whether a device may be granted.src/core/registry.ts— stampleaseIdentityinregisterDevice; read it inparseDevice, defaulting a record written without it to"reusable".src/core/acquisition-planner.ts— apply the predicate to the ready scan and the shutdown scan.src/core/warm-pool-coordinator.ts— for a fresh device, reclaim is shutdown, commitshutdown, then destroy. A failed destroy hands off to quarantine the way a failed purge does.src/core/quarantine-coordinator.ts— retry a fresh device's delete instead of its reclaim. Success reachesdeleted.src/core/startup-converger.ts— finish the delete of every spent fresh device found at start.src/contract/schemas.ts— the config schema for the new block.docs/CONFIGURATION.md,docs/EVENTS.md,docs/internal/EVENTS.md.Contract and event changes
config.getreturns the newlease.identityblock. No new daemon operation, no request field, and the grant is unchanged.device.purge-failed—attemptedStrategygains"delete", emitted when a fresh device's delete fails. This widens a published vocabulary. Record it in both EVENTS.md cuts, and say there that a consumer must tolerate a strategy it does not know.device.deleted— emitted with initiatorlease-endwhen a fresh device's delete completes. Payload unchanged.device.reclaimed— not emitted for a fresh device. Nothing was reclaimed.device.quarantined,device.quarantine-abandoned,device.quarantine-stranded— unchanged, and now also reachable from a failed delete. Update their rows to say so.device.quarantine-recovered— unchanged, and still means a device rejoined the pool. A fresh device whose retried delete succeeds emitsdevice.deletedinstead.Rules in play
architecture.md1, 2, 3 — the core readslease.identityby platform key and never branches on platform. No iOS concept enters the core.architecture.md6 — no state is added. The state machine already has every transition this task uses.architecture.md10 — one grantability predicate, called by the acquisition planner and by quarantine. Two copies would diverge.safety.md— destruction stays registry-only. A spent device is still a registry record, which is what authorises deleting it.events.md6, 8 — the vocabulary widening is documented in the same change.testing.md— every test title below is a claim its body must prove.Tests
deletedand its driver device is destroyed.freshis still deleted after configuration changes toreusable.reusablestill reclaims and rejoins the pool.reclaimingdeletes the device on the next start.shutdowncommit and before the delete deletes the device on the next start.deleted.quarantinedand ungrantable, and emitsdevice.purge-failedwith strategydelete.deleted, neverready.leaseIdentityexisted loads with every devicereusable.lease.identityvalue other thanreusableorfreshfails config validation naming the key.Timeouts
None added. Measured on an M-series Mac with iOS 26.4 and a shut-down device: create 0.3 s, delete 0.3 s, boot to usable about 29 s, the same boot an erased device pays. The shared command timeout covers the delete. Under
fresh, no device waits warm after a release, so every lease pays that boot.docs/CONFIGURATION.mdsays so next to the knob.Completion conditions
device.purge-failedwidening and the newdevice.deletedinitiator.docs/CONFIGURATION.mddocuments the knob and the boot cost.Out of scope
erasecommand timeout.Depends on
None.
Approval
Written by an agent.