Skip to content

feat(devices): Simlock-owned device roots for iOS and Android (ADR 0001) - #111

Open
V3RON wants to merge 7 commits into
mainfrom
feat/owned-device-roots
Open

feat(devices): Simlock-owned device roots for iOS and Android (ADR 0001)#111
V3RON wants to merge 7 commits into
mainfrom
feat/owned-device-roots

Conversation

@V3RON

@V3RON V3RON commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Implements ADR 0001 — Simlock-owned device roots. This is the integration branch for the five-PR stack that has already been reviewed and merged into it; this PR is what actually lands that work on main.

# PR What it added
1 #80 Owned device root validation (ensureOwnedRoot, loadInstanceId) and its ports
2 #81 iOS: every simctl call scoped with --set, membership-based listManaged, fail-closed discovery
3 #82 Android: ANDROID_AVD_HOME, a private supervised adb server, consoles above the default scan ceiling
4 #83 Lease environment and the scoped simlock simctl / simlock adb passthroughs
5 #84 doctor --purge-orphans, legacy pre-root devices, ADR status flipped to Accepted

What changes

Ownership stops being a guess about a device's name and becomes a fact about its location.

  • A device root is proven, not assumed. ensureOwnedRoot creates a root only when it creates it empty itself, and refuses one that is unmarked, marked for another instance, symlinked, or wrongly owned or permissioned. The root is assembled in a staging sibling and published with a single rename, so creation is atomic with its marker.
  • Both drivers answer listManaged from root membership. A simlock- name prefix survives only as the cosmetic label a human reads in simctl list — it is no longer evidence of ownership, so an identically-named user device is never adopted.
  • A refused root costs that platform and nothing else. Never a fallback to the default device location; reported as driver.root-rejected at startup and as a doctor finding on every run after.
  • Containment cuts both ways. A grant carries a driver-built environment the core never reads, --export-env prints it for eval, and the scoped wrappers let a lease holder reach a contained device without handing back the capability the containment removed.
  • doctor --purge-orphans is the single opt-in exception to registry-only destruction, behind its own flag and a confirmation, with every root re-proven before anything is destroyed.

Two ADR corrections made during implementation

  • feat(android): Simlock's own AVD home and adb server (3/5) #82 amends decision 4. ADB_LOCAL_TRANSPORT_MAX_PORT=5683 alone is incomplete and actively harmful: adb's emulator scan has no minimum-port variable, so raising the ceiling only widens a sweep that always starts at 5555 — Simlock's server would have connected to the user's own emulators. ADB_EMU=0 is what actually contains it. The ADR carries a dated Correction with citations.
  • feat(doctor): purge orphans on request, and only on request (5/5) #84 amends docs/ARCHITECTURE.md, which claimed Simlock cannot address anything outside a root. destroyLegacy breaks that, by design and per the ADR's migration paragraph.

State of this branch — read before merging

It does not merge cleanly. The branch was last rebased at 67255ca, and main has since taken #68, #69, #89, #90 and the first six PRs of the ADR-0003 typed-contract stack (#92#97). 32 files conflict, including src/daemon/main.ts, src/daemon/server.ts, src/cli/index.ts, src/core/doctor.ts, src/core/driver.ts, both driver entry points, the MCP contracts and session, and six docs. docs/adr/0001-simlock-owned-device-roots.md conflicts add/add because #89 already carried the ADR text onto main — the decision doc landed there without its implementation.

The conflicts against the 0003 stack are the substantive ones: that stack reworked the daemon dispatcher, the client and the MCP surface, which is where most of these 32 files overlap.

#91 is not included here. It is stacked on feat/owned-device-roots-5-doctor and never merged upward, so the B1/B2/D1/D2/D3 fixes from the hardware verification run — daemon-stop leaving the process alive, a rejected root crashing the whole daemon, the socket-path length check, the Android boot estimate, and stale addresses on quarantined devices — are absent from this branch. It needs to be rebased onto this branch (or onto main after this lands) before the stack can be called complete.

Verification

Each constituent PR was green on its own base at merge time (pnpm run check: typecheck, typecheck:e2e, lint, format, unit tests, e2e lane with the fake driver; fallow audit clean). This integration branch has not been verified against current main and cannot be until the conflicts are resolved.

The slow real-hardware lanes were never run: no macOS, no Xcode, no Android SDK in that environment. Specifically unverified on real tooling — host:emulator:<port> re-attaching an emulator started under a previous adb server, ps -o args= rendering under BSD ps, and unref() letting the daemon exit with the server running.

Refs #73, #70

V3RON and others added 7 commits September 2, 2026 09:05
Adds ADR 0001 and updates the docs to the state the implementation should
reach, so anyone picking the work up reads the target rather than today's
behaviour.

Extends issue #73 (iOS device set) to Android, because deciding the two
separately would leave two incompatible ownership models in one product.
Each driver owns a marked root under SIMLOCK_HOME and scopes every platform
command to it: iOS via `simctl --set`, Android via ANDROID_AVD_HOME plus a
private adb server on a port the shared server does not scan.

This replaces ownership *inference* (the `simlock-` / `simlock_` name
prefixes that `listManaged()` matches on today, which silently adopt a
user's identically-named device) with ownership *proof* by root membership.
The registry keeps its role unchanged: the root is the authoritative device
inventory, the registry the authoritative device state.

Docs are marked "Accepted - not yet implemented"; AGENTS.md explains that
convention so the gap is not mistaken for drift.

Refs: #73, #70
First of five stacked PRs implementing [ADR
0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md).
Base is the ADR branch; each later PR is based on the previous one.

1. **owned device root validation and its ports** ← this PR
2. iOS: `simctl --set`, membership-based `listManaged`, fail-closed
discovery
3. Android: `ANDROID_AVD_HOME`, private supervised adb server
4. lease environment and the `simctl` / `adb` passthroughs
5. doctor: `--purge-orphans`, legacy pre-root devices, docs status

## What this is

The foundation both drivers need before either can own a device root.
Nothing observable changes: `ensureOwnedRoot` and `loadInstanceId` have
no production caller until PR 2 wires them.

- **`ensureOwnedRoot`** (`src/core/device-root.ts`) — the whole of
Simlock's structural ownership proof. It creates a root only when it
creates it empty itself, and refuses an existing one that is unmarked,
marked for another instance, symlinked, or wrongly owned or
permissioned. Validation is pure filesystem logic, so it lives in the
core once rather than per driver (ADR decision 2).
- **`loadInstanceId`** (`src/core/instance-identity.ts`) — writes
`instance.json` once and never regenerates it. A corrupt file fails
loudly, because regenerating would strand every device in the root
behind `wrong-instance`.
- **The `drivers` config section** — stored, merged and forwarded
without the core interpreting a single key, so a driver owns its
settings end to end (architecture rule 2).
- **Ports** — unfollowed `lstat`, exclusive `mkdir`, `chmod`,
`realpath`, `rename`, `writeFileExclusive`, plus `ProcessSupervisor` and
`TcpProbe` for PR 3's supervised adb server, and `stdio: "ignore"` so a
long-lived child's output is not buffered forever.

## Decisions worth reviewing

**An ancestor symlink is deliberately not a rejection.** `/tmp` is a
symlink on macOS and every test home lives under one, so rejecting on a
`realpath` mismatch would fail closed on ordinary machines. Only the
root itself and its marker are checked unfollowed — an attacker who can
swap an ancestor can equally swap the root, and that case *is* caught.

**Two rejection reasons were added** beyond the list in
`docs/EVENTS.md`: `not-absolute` and `unreadable` (the EVENTS.md row is
updated). Both exist so a bad configuration skips one platform instead
of stopping the daemon.

**Declined: binding the marker to the root it sits in.** A valid marker
is portable, so a restored or copied root validates if `deviceRoot` is
pointed at it. Fixing that means adding a field to the four-key schema
the ADR fixes, for a risk that needs someone to aim `deviceRoot` at a
copy that also holds their own files. Flagging rather than deviating
from an accepted ADR — say the word and it goes in.

## Review round

The third commit is the fix round from an adversarial review of the
first two. Four defects were confirmed against a real filesystem, not
argued:

- **Root creation was not atomic with its marker**, which is the
property ADR decision 1 promises. Three concurrent calls on a fresh path
gave one success and two `non-empty-unowned-root` refusals — the "data"
being Simlock's own marker tempfile mid-rename. A crash or ENOSPC
between the steps bricked the root permanently. A root is now assembled
in a staging sibling and published with one `rename`.
- **`loadInstanceId` could overwrite the identity it promises never to
regenerate** (`exists()`-then-write over a clobbering `rename`),
stranding every root the winner had just marked. Now an exclusive
create.
- **`ProcessSupervisor` passed pid `0` and `-1` to `process.kill`** — a
corrupt `adb-server.json` in PR 3 would have signalled the daemon's
process group, or every process the user owns.
- **A relative `deviceRoot` resolved against the daemon's inherited
cwd**, and unexpected errno errors escaped untyped.

Each fix has a test that fails against the old implementation (21 in
total, verified by swapping the old files back in).

## Known residuals

- A crash between creating the staging directory and the rename leaves a
hidden `.staging` sibling behind. It is outside the root, so it never
affects validation or the emptiness count. Nothing sweeps it.
- POSIX `rename` replaces an existing *empty* destination directory, so
a user directory created in the sliver between the `lstat` and the
`rename` would be replaced. Strictly narrower than what it replaces;
documented at the call site.
- The real-filesystem race test for `loadInstanceId` passes against the
old code too — with three racers the old re-read converged every run.
The double that seeds the winner's file and throws `EEXIST` is what
catches that mutation.

## Verification

`pnpm run check` passes in full: typecheck, typecheck:e2e, lint, format,
786 unit tests (2 pre-existing skips), and the e2e lane (33 passed, 1
expected fail, 3 skipped — identical to the pre-change baseline). `pnpm
exec fallow audit` reports no issues in 29 changed files.

Refs #73, #70

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…#81)

Second of five stacked PRs implementing [ADR
0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md).
Based on #80.

1. [#80](#80) owned
device root validation and its ports
2. **iOS: `simctl --set`, membership-based `listManaged`, fail-closed
discovery** ← this PR
3. Android: `ANDROID_AVD_HOME`, private supervised adb server
4. lease environment and the `simctl` / `adb` passthroughs
5. doctor: `--purge-orphans`, legacy pre-root devices, docs status

## What this is

Ownership stops being a guess about a name and becomes a fact about a
location.

- **`listManaged` answers from set membership.** It used to report
whatever was called `simlock-`, which adopts a user's identically-named
simulator on sight. The naming survives only where devices are
*created*, as the cosmetic label a human reads in `simctl list` (safety
rule 8).
- **Every `simctl` invocation carries `--set <deviceRoot>`**, through
the one spawn site the driver already funnelled them into.
- **The driver stops learning where its devices live** by parsing
`dataPath` out of simctl output and reads the root from config, which is
what lets the provenance mark paths be derived without a subprocess on
the reclaim path.
- **Construction is asynchronous** because it has to be: a driver that
has not proven its root can address devices it cannot prove are
Simlock's. A refused root costs that platform and nothing else — never a
fallback to the default device location (safety rule 9) — and is
reported as a `driver.root-rejected` event at startup and a `doctor`
finding on every run after.
- **`Driver` gains `deviceRoot` and `leaseEnvironment()`**, both opaque
to the core (architecture rule 2). Android gets placeholder values here;
PR 3 replaces them.

## Review round (second commit)

An adversarial review found ten defects. The worst two:

**`doctor --fix` deleted a platform's entire registry inventory when its
driver refused to start.** Doctor builds reality from the drivers it
*has*, so every device of a dark platform read as
`registry-device-missing` and `--fix` marked each one `deleted`. The
report contained both `driver-unavailable` ("this platform is
unobservable") and `registry-device-missing` ("its devices are gone")
and acted on the second — leaving the simulators on disk with no
registry record able to reach them, which is the permanent
multi-gigabyte leak the ADR opens by describing, reachable with a
`chmod`, through a command `docs/CLI.md` expects to run unattended in
CI. **You cannot conclude "the device is missing" from "I could not
look."** A platform with no driver now produces no existence, run-state,
provenance or orphan finding at all. Gated on driver *presence*, so it
also closes the same hole for a missing Android SDK, which predates this
stack.

**The fail-closed path had no executing test.** `throw new
Error("MUTANT")` on the first line of `discoverIosDriver` passed all 797
tests, because the branch is gated on `process.platform === "darwin"`
and CI is Linux. Discovery now takes the platform from its context, and
the catch, the rethrow, the log and the rejection payload — built from a
real `OwnedRootError`, so renaming `root` to `path` in the published
payload now fails CI — are all covered.

Also fixed: a non-string `deviceRoot` took down the whole daemon
including Android while `""` and a relative path cost only iOS;
`NO_DRIVER` on a refused platform reported no reason despite safety rule
9 promising one; `#writeMark`'s `Promise.all` could leave a durable mark
without its erasable half, making Doctor accuse the user of an erase
Simlock itself performed; the runtime event-payload cast would have let
PR 3 emit an Android payload contradicting `docs/EVENTS.md`; a relative
`SIMLOCK_HOME` silently disabled iOS; and a mis-scoped `simctl` call
made the suite hang for 100s instead of failing in 2ms.

## Known residuals

- **Ownership is proven at startup and trusted for the life of the
process.** Replacing the root under a running daemon (a `mv`, a symlink)
would have `listManaged` report the user's own simulators as ours. PR 5
re-validates before `--purge-orphans` destroys anything — reporting can
live with a stale proof, destroying cannot — and documents the gap in
`known-pitfalls.md`.
- **`driver-unavailable` is a startup snapshot**, re-reported until the
daemon restarts. The finding now says so.
- The slow iOS lane was updated to the new contract but **cannot be
executed here** (no macOS, no Xcode). It is the only part of this PR not
covered by a run.

## Verification

`pnpm run check` green in full: typecheck, typecheck:e2e, lint, format,
811 unit tests, and the e2e lane (33 passed, 1 expected fail, 3 skipped
— matching the pre-change baseline). `pnpm exec fallow audit` clean
across 23 changed files.

Refs #73, #70

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Third of five stacked PRs implementing [ADR
0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md).
Based on #81.

1. [#80](#80) owned
device root validation and its ports
2. [#81](#81) iOS:
`simctl --set`, membership-based `listManaged`
3. **Android: `ANDROID_AVD_HOME`, private supervised adb server** ← this
PR
4. lease environment and the `simctl` / `adb` passthroughs
5. doctor: `--purge-orphans`, legacy pre-root devices, docs status

## What this is

Android has no `simctl --set`, so containment is built rather than
inherited. AVDs live in a root this driver created and proved; every
`adb`, `emulator` and `avdmanager` call carries `ANDROID_AVD_HOME` and
`ANDROID_ADB_SERVER_PORT` through one insertion point; console ports
move to 5586–5682, above the 5585 ceiling a default adb server scans.
`listManaged` answers from membership in that root — a name prefix is
not evidence, and neither is being visible to our server.

The server is supervised by pid because it has to be:
`ADB_REJECT_KILL_SERVER=1` means an agent's reflexive `adb kill-server`
cannot detach every leased emulator at once, and neither can Simlock. It
runs as `nodaemon server` so the recorded pid is the server rather than
a launcher that exits, and `adb-server.json` lives outside `state.json`
because a corrupt registry is exactly when a leftover server must still
be reapable. An occupied port refuses the platform rather than attaching
to whatever is listening, which could be Android Studio's.

## This PR amends ADR decision 4

The record specifies `ADB_LOCAL_TRANSPORT_MAX_PORT=5683` alone. Verified
against adb's source, that is **incomplete and actively harmful**: the
emulator scan has no minimum-port variable, so the ceiling only widens a
sweep that always starts at 5555.

```c
for (int port = DEFAULT_ADB_LOCAL_TRANSPORT_PORT; port <= adb_local_transport_max_port; port += 2)
    connect_emulator(port);   // Note, uses port and port-1
```

Simlock's server would therefore have discovered and connected to *the
user's own emulators*, leaving two adb servers contending for one device
— the accident this ADR exists to prevent, running the other way.
`ADB_EMU=0` is what actually contains it, and our emulators still attach
because the emulator announces itself with `host:emulator:<port>`. The
ADR carries a dated Correction with the citations; `CONFIGURATION.md`
and `known-pitfalls.md` match.

Containment is now symmetric: our consoles sit above the user's default
ceiling so their server cannot see ours, and the scanner is off so ours
never looks at theirs.

## Review round (second commit)

An adversarial review found eight defects. The worst was a design flaw,
not a slip:

**A clean `daemon stop` orphaned every running emulator, permanently.**
Devices are *meant* to survive a restart — releasing a lease hands the
device to the warm pool. But shutdown reaps the adb server, and an
emulator announces itself exactly once, to the server running when it
booted. With the scanner off nothing found them again: `adb devices`
empty, every running AVD reporting `stopped`, no orphan finding able to
name them (several GB of RSS each, forever), and the allocator
re-issuing 5586 to a device that then could not bind — the occupancy
defect this ADR set out to fix, arriving from the other side. The design
defended the crash it anticipated and broke on the orderly shutdown it
did not.

Simlock now does deliberately, for its own bounded range, what adb's
scanner would have done for everyone's: after starting or adopting a
server it announces every console port in 5586–5682. That also makes the
design independent of whether a running emulator re-announces itself,
which cannot be tested here.

**The pid identity check would have killed the user's adb server.** `ps
-o comm=` strips the arguments that make a server ours, so it proved
"some adb" — and on a developer's machine a recycled pid most likely
belongs to the user's own server. It now requires an adb binary, our
port, and `nodaemon`.

Also fixed: a failure after spawn hung the daemon (the child was never
`unref`ed) *and* disabled Android permanently, because the record was
written after the wait; a disk error writing that record took iOS down
too; a spawn that never produced a process crashed the daemon through an
unhandled `error` event no caller could catch; the port probe believed
any listener, so a cold-start race let the loser clobber the winner's
record; and an inconclusive `ps` at shutdown deleted the record while
leaving the server alive, turning the first clean shutdown on a host
without `ps` into a permanently dead platform.

## Not verified here

No macOS, no Android SDK, no `adb` — every branch is unit-tested against
fakes. Specifically unverified on real tooling: that
`host:emulator:<port>` re-attaches an emulator started under a
*previous* server (the restart-recovery path — the first experiment
worth running on a Mac); that `ps -o args=` renders the command line as
assumed under BSD `ps`; that `unref()` lets the daemon exit with the
server running; and both slow lanes.

## Verification

`pnpm run check` green: typecheck, typecheck:e2e, lint, format, 865 unit
tests, e2e (33 passed, 1 expected fail, 3 skipped — matching baseline).
`fallow audit` clean across 28 changed files.

Refs #73, #70

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…wrappers (4/5) (#83)

Fourth of five stacked PRs implementing [ADR
0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md).
Based on #82.

1. [#80](#80) owned
device root validation and its ports
2. [#81](#81) iOS:
`simctl --set`, membership-based `listManaged`
3. [#82](#82) Android:
`ANDROID_AVD_HOME`, private supervised adb server
4. **lease environment and the `simctl` / `adb` passthroughs** ← this PR
5. doctor: `--purge-orphans`, legacy pre-root devices, docs status

## What this is

Containment cuts both ways, and PRs 2 and 3 only built the outward half.
A simulator in Simlock's device set and an emulator on Simlock's adb
server are unreachable with a bare `simctl` or `adb` — which is the
point for everyone else, and a problem for the agent holding the lease.
Until this lands, `simlock lease --json` returns a `udid` no documented
workflow can address (ADR decision 7).

- **A grant carries an `environment`** the driver built and the core
never reads — the device-set path on iOS, the adb server port on
Android. Set at the single `LeaseGrant` construction site, forwarded
verbatim, so a third driver contributes its own scoping without a core
edit (architecture rule 2).
- **`--export-env`** prints it as shell exports for `eval`, quoted so a
path with a space or an apostrophe survives the round trip. The e2e test
asserts that through a real `/bin/sh`, not a string comparison.
- **`simlock simctl` and `simlock adb`** inject the scoping and pass
every other argument through. Each driver owns the verbs its wrapper
refuses, because handing those back would restore the exact capability
this work removes.
- The client parses `environment` leniently, and only that field: an
older daemon sends none, and a grant is still worth having without one.

## Review round (third commit)

An adversarial review found the most serious defect in the stack so far:

**`simlock simctl --profiles /tmp erase all` was proxied.** The
subcommand was taken as the first argument not starting with `-`, and
simctl's two global options put their value in a *separate* argv entry —
so `/tmp` read as the subcommand, no refusal fired, and the daemon
returned `xcrun simctl --set <simlockRoot> --profiles /tmp erase all`.
**Simlock supplied the containment path itself**, erasing every
simulator in the root including devices under another agent's live
lease. The barrier was supposed to be knowing that path. A
caller-supplied `--set`/`--profiles` is now refused outright, which
makes "the first non-flag argument is the subcommand" true by
construction.

Worth stating honestly: `simlock simctl list devices -j` prints
`dataPath` for every device, so a *determined* user learns the root
anyway, and the ADR says this is not a security boundary. What the
refusal buys is the typo-and-reflex guard — and that was broken.

**The refusal list was a list of words, not a model of the rule.** It
let through verbs worse than the ones it stopped: `simctl shutdown all`
(the iOS analogue of `adb kill-server`, which Android refuses for
exactly that reason — it stops every device Simlock believes is running,
for every agent, burning each lease's recovery budget), `adb emu avd
stop`, and `adb emu avd snapshot delete` (which destroys the baseline
every later reclaim restores from, silently degrading reclaims from
seconds to a full wipe). Bare tooling cannot reach any of those devices,
so each was a returned capability. `simctl runtime delete` is not in
that class but is now refused too — a wrapper offered as the safe path
should not proxy something it cannot undo and Simlock will not
re-download.

Also fixed: `--export-env` escaped values against every pathological
input and did not escape keys at all, so a driver-supplied key could
carry a command into the `eval` the flag exists for (confirmed by
execution); and against an older daemon it exited 0 having printed
nothing, leaving a lease committed, TTL-bound and unnameable.

## Not amended

ADR decision 7's `(delete, erase, create, emu kill)` is illustrative;
the added refusals apply that decision rather than change it, so the
record stands as written.

## Worth your judgement

**The passthrough requires no lease.** Any local process can `simlock
adb shell …` against a device another agent holds. That may be intended
operator convenience, but it makes the refusal list the only thing
between one agent and another agent's device.

## Verification

`pnpm run check` green: typecheck, typecheck:e2e, lint, format, 927 unit
tests, e2e 38 passed / 1 expected fail / 3 skipped (five new cases,
including the real-`/bin/sh` export round trip). `fallow audit` clean
across 34 changed files. The `slow-*` lanes gained passthrough and
environment assertions but **cannot run here** — no macOS, no Xcode, no
Android SDK.

One unrelated defect surfaced and was deliberately not absorbed: `daemon
stop` does not exit while a detached lease is outstanding. It reproduces
on `feat/owned-device-roots` with none of this stack applied; the new
e2e test releases its lease with a comment pointing at it.

Refs #73, #70

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Fifth of five stacked PRs implementing [ADR
0001](https://github.com/callstackincubator/simlock/blob/feat/owned-device-roots/docs/adr/0001-simlock-owned-device-roots.md).
Based on #83. **This is the PR that marks the ADR implemented.**

1. [#80](#80) owned
device root validation and its ports
2. [#81](#81) iOS:
`simctl --set`, membership-based `listManaged`
3. [#82](#82) Android:
`ANDROID_AVD_HOME`, private supervised adb server
4. [#83](#83) lease
environment and the scoped wrappers
5. **doctor: `--purge-orphans`, legacy pre-root devices, docs status** ←
this PR

## What this is

Every earlier PR made Simlock able to *prove* what it owns. This one
makes it *destroy* on the strength of that proof, which is why the
wiring matters more than the feature.

**`doctor --purge-orphans`** reclaims a device that sits inside a
validly-marked root with no registry record — almost always a daemon
that died between creating a device and writing it down, and until now
permanently unreclaimable, because registry-only destruction cannot
reach what the registry has never heard of. It is the single opt-in
exception safety rule 1 allows, and it is deliberately hard to reach by
accident: its own flag rather than part of `--fix` (so a `doctor --fix`
already running unattended in CI does not acquire a destructive
behaviour on upgrade), a confirmation unless `--yes`, and no
reachability from the reaper, a cleanup rule, or an idle tier.

The reason it must stay opt-in is structural. Every central safety
filter is written over registry records, and an orphan has none — so an
orphan proposal bypasses the safety net rather than passing through it,
including a device this very daemon is mid-provision on. That reasoning
is written at the purge site.

**Roots are re-proven before anything is destroyed.** Ownership is
established once at startup and then trusted for the life of the process
— fine for reporting, not fine for destroying, since a daemon up for
days is one `mv` or symlink away from a root that now holds the user's
own simulators. Every root a purge would touch is re-validated first via
a new `Driver.revalidateRoot()`, and a refusal aborts the *whole* run
rather than one platform, so a half-purge is impossible rather than
merely unlikely.

**Legacy pre-root devices** are reported as `legacy-device` rather than
as vanished, and `--fix` destroys them through their old unscoped path.
That is the one destruction that reaches outside an owned root, and it
is permitted because the device is in the registry: registry-only
destruction is satisfied by the record, not by the root (ADR Migration
paragraph).

## Docs stop describing a future

ADR 0001 status → `Accepted`; `device.orphan-purged` → `implemented`;
the `(planned)` markers and "after ADR 0001 lands" notes out of
`known-pitfalls.md`.

Two doc-truth findings surfaced while flipping the status:

- **`docs/ARCHITECTURE.md` was made untrue by this PR.** It said
"conversely Simlock cannot address anything outside [the root]", which
`destroyLegacy` breaks. Amended with the migration exception and the
startup-proof / re-proof split rather than left standing as a false
statement.
- **A new pitfall entry**: a root's ownership is proven at startup and
trusted for the daemon's life. The existing "accident boundary, not a
security boundary" entry covers someone deliberately passing `--set`; it
said nothing about the root being replaced under a running daemon.

Nothing in the ADR's decisions needed changing, which is why the status
flip is honest.

## Deliberate gaps

- `DoctorReport`'s shape is unchanged, so a confirmed purge that was
refused by root re-validation signals that only through the orphan
findings still being present, plus a `daemon.log` line. An explicit
"purge refused" in the report would be a reasonable follow-up.
- `destroyLegacy` does not stop a running legacy emulator — doing so
would mean driving the user's own adb server.
- `docs/EVENTS.md` still carries a `> Status: **planned catalog**`
header even though every row now reads `implemented`. Left alone as out
of scope; worth a separate tidy.

## Verification

`pnpm run check` green: typecheck, typecheck:e2e, lint, format, 949 unit
tests, e2e 40 passed / 1 expected fail / 3 skipped (two new fake-driver
flows: a purge with its confirmation gate, and a refused root destroying
nothing). `fallow audit` clean across 24 changed files.

Nothing in this PR has been exercised against a real `simctl` or
`avdmanager` — no macOS, no Xcode, no Android SDK here. The `slow-*`
lanes were reviewed and needed no change; they remain unrun.

Refs #73, #70

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BuTqSL7fKJVRbFytNvZP7X)_

Co-authored-by: Claude <noreply@anthropic.com>
Brings ADR 0001's owned device roots onto the ADR 0003 typed-contract daemon.
Both landed independently and overlapped across 34 files; this resolves them.

The substantive ports, rather than textual resolutions:

- `src/daemon-client` is gone (0003 replaced it with `src/simlock-client`), so its
  two additions moved into the contract: `environment` is a required field on
  `leaseGrantSchema`, and `driver.passthrough` is a declared operation dispatched
  like any other instead of a hand-written case in the socket switch.
- `PASSTHROUGH_REFUSED` and `UNKNOWN_PASSTHROUGH_TOOL` became rows in the shared
  `ERROR_TABLE` with `classifyError` branches, so both transports agree on them --
  the drift ADR 0003 §7 exists to prevent.
- `doctor.run` gained `purgeOrphans`, admin-gated alongside `fix` because it
  destroys.
- `discoverDrivers` keeps the per-driver `{driver, rejection}` shape a refused root
  needs, and gained main's slim / disk-guard / download-timeout / diagnostics
  threading.
- The iOS driver keeps the derived `#dataPathFor` -- owning the set means the path
  is known -- and main's `#checkAlreadySlimmed` was ported onto it, losing the
  `undefined` branch that only existed because the old path could fail to find one.

The e2e fake driver now defaults its lease environment to deliberately
unmistakable values (`SIMLOCK_FAKE_TRACER`, plus one carrying a space and an
apostrophe). Nothing reads them; they exist to be recognised at the far end, which
is what proves the map crossed every layer untouched rather than being rebuilt.

Known gap: 28 unit tests fail (17 iOS, 9 daemon/server, 2 android). All are test
fixtures reacting to the merged contract -- typecheck and typecheck:e2e are clean
and no production path is knowingly broken. The iOS ones need an ownership marker
seeded where a test plants a device under the root; doing that surfaces a separate
order-dependent hang in that file which is still undiagnosed. Tracked for a
follow-up, not fixed here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant