Skip to content

Create a new iOS simulator for each lease #75

Description

@thymikee

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.

  1. Create a simulator for one lease. Boot and check it.
  2. Grant it at most once.
  3. The lease ends through release, expiry, or recovery-driven termination. The device enters reclaiming as today.
  4. For a fresh device, reclaim is a driver shutdown followed by a commit to shutdown. No erase.
  5. 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.tsDeviceRecord.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-failedattemptedStrategy 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

  • Approved for delivery

Written by an agent.

Activity

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

Metadata

Metadata

Assignees

Labels

task:readyAn agent may implement it.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions