Files
callstack__agent-device/docs/dependency-graph-findings.md
T
Michał Pierzchała 56b72c5cf7 refactor(boundaries): put shared contracts below their consumers, gate the result (#1405)
* refactor(boundaries): move shared contracts below their consumers

Acts on the depgraph findings: type-only edges are invisible to R5, so
vocabulary that everything depends on had drifted above the zones that use it.

- contracts/: the four platform-plugin facet tags (LogBackend,
  RecordingBackendTag, PerfMetricsSamplerTag, PlatformGatedProviderResolverKey)
  now live beside the plugin contract itself, which also moves out of core/;
  NetworkEntry moves next to the command surface that renders it; and the
  click-button, recording-export-quality, interactor-types and
  runner-lease-context vocabularies move down out of core/.
- (root) drops from 29 files to 13: the internal *-contract/output/annotation
  modules move into contracts/, kernel/ (daemon-error, observability-redaction
  beside kernel/redaction), core/ (batch-policy, an ADR 0008 projection),
  commands/ (cli-command-aliases) and remote/ (upload-progress, upload-stream).
  What remains is entrypoints and the composition roots that R2 requires to
  sit outside the spine.
- utils/ joins the ranked spine at rank 1 after its only two upward files move
  to the zones they were reaching for (cli/resolve-cli-options,
  cli-schema/cli-config), putting ~336 value edges under the gate.
- Internal imports that routed types through the client-types re-export hub now
  name their real source.

Type-only spine inversions drop from 61 to 35; the remainder is two clusters
(client/client-types.ts and the ADR 0003 daemon facet). No behaviour change:
4470 unit tests and the layering gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* style: merge the duplicate contract imports the tag moves created

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(imports): name the declaring module, share find's argument rules

Two follow-ups from re-measuring the graph after the boundary moves.

1. 89 type imports across 79 files routed through a re-export hub in another
   zone: `CliFlags` reached through commands/cli-grammar/flag-types.ts (52) when
   it is declared in contracts/cli-flags.ts, the replay suite result types
   reached through daemon/types.ts when they are declared in contracts/replay.ts,
   the doctor types through a daemon handler module, and so on. Each hop invented
   a cross-zone edge the architecture never asked for — including every apparent
   replay -> daemon and utils -> commands dependency. They now name the module
   that declares them. Within-zone hops are left alone; those are a local style
   choice, not a boundary claim.

2. `find`'s three positional/flag checks existed in both daemon entry points with
   hand-repeated messages, and the copy in dispatchFindReadOnlyViaRuntime was
   unreachable — its only caller validates first. Both now call checkFindArgs in
   selectors/find.ts, beside parseFindArgs and isReadOnlyFindAction, for the
   reason that module's own comment already gives: so the two paths cannot
   disagree. The refusal is returned rather than thrown, because the two
   mechanisms are not observationally identical in the session event log.

Type-only spine inversions: 61 -> 35. 4470 unit tests and every gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(layering): ratchet type-only spine inversions (R6)

R5 ignores type-only edges by design — they cost nothing at runtime and do not
affect cold start — so nothing was watching the direction they point. Ranking
them the same way found 61 inversions, including contracts/ and utils/ declared
in terms of rank-4 zones. 26 are fixed by the preceding commits; R6 pins the
rest per zone pair so they can only shrink, and a new pair fails outright rather
than being added to the baseline.

The two remaining clusters each need their own change, and the baseline says so:
the per-command Options/Result vocabulary declared inside the public Node-client
surface, and the ADR 0003 daemon facet shape that core's descriptor registry
composes.

Both ratchet directions are covered: growth fails, and shrinking without
lowering the number fails too, so the baseline cannot quietly stop describing
the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: record the import-graph findings behind this refactor

A dated snapshot, not a normative document: when it disagrees with
scripts/layering/, the gate wins. The graph tool that produced it lives on the
claude/depgraph-viewer branch, deliberately out of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(selectors): state the shared selector argument rules once

R2 (commands-floor) forbids the daemon from importing commands/, and that is the
right call: commands/ is the client-side surface — its only consumers are cli/,
cli-schema/, mcp/, client/ and the composition roots — while the daemon is the
executor on the other side of the wire. ADR 0008 protects exactly that seam.
Relaxing R2 would let the executor depend on a client projection and pull CLI
grammar and output formatting into the daemon's bundle.

But the rule does force duplication: the daemon must validate independently
because it accepts requests from any client, so 10 refusal messages existed in
both zones. The only place a shared rule can live is below both, and selectors/
already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs,
isSupportedPredicate) and even the `is` predicate message — just not the checks
that use them.

Three drifts had already appeared in the `is` predicate rule alone:

- commands/interaction/selectors.ts re-implemented the predicate list as an
  inlined seven-way `!==` chain while importing the message and hint from
  selectors/predicates.ts, so adding a predicate to the shared list would not
  have reached the CLI grammar.
- That inlined chain compared the raw token, so the CLI rejected `is TEXT ...`
  while the daemon it hands the command to accepts it. The CLI now matches the
  executor; this is an intentional alignment, not an accident.
- isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether
  an agent got recovery guidance depended on which layer noticed first — the
  failure mode ADR 0010's audit calls out.

checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs and
checkWaitText now hold those rules, each beside the parser it wraps, and report
a refusal rather than choosing how to raise it: the daemon returns a response,
the command surface throws. Those mechanisms are not interchangeable — they
write different session events — so the shared check stays out of that decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(daemon): give ADR 0014's ref frame one transition, pin SessionState owners

`SessionStore.get()` returns the live record out of a private Map and `set()`
re-puts the same reference, so every `session.<field> = …` in the daemon is a
durable write to store-owned state: 57 of them across 17 files, against 26
`set()` calls that are therefore ceremonial. Nothing at the store boundary can
check what those writes are supposed to keep true.

Measuring which module writes which field showed the problem is narrower than
the raw count suggests — 16 of 27 fields already have exactly one writer. The
sharp case is ADR 0014's ref frame: `refFrameState`, `refFrameScope`,
`refFrameTree` and `refFrameGeneration` must move together or the frame is
incoherent (an `active` state with a stale tree resolves refs against a
namespace nobody authorized), yet complete issuance wrote them in ref-frame.ts
and partial issuance wrote the same four in session-snapshot.ts. ref-frame.ts's
own header claims to be "the single owner of the frame's transitions", and
session-snapshot.ts documented itself as the exception. Both forms now go
through `activateRefFrame`; they differ only in scope.

`recordSession` deliberately moves alone in two paths (recording without arming
a publication), so the save-script cluster gets no invented abstraction — it
gets ownership instead. R7 records every field's owner and stops the set from
growing quietly: a new SessionState field must declare one, a foreign write
fails naming the owner to call, and an owner that stops writing must be removed
so the table cannot drift into fiction. Field names are read out of the
`SessionState` declaration, so a daemon module with an unrelated local named
`session` — a provider or runner session — cannot trip it.

4475 unit tests and every gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: record the reference semantics and refresh the findings

SessionStore.get/set now document that the record is handed out live, since that
is the fact behind R7. The findings snapshot picks up the resolved R2 question,
the ref-frame consolidation and the two new gate scopes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(boundaries): rank every satellite zone, extract the provider port

Second-order effect of the earlier rounds. With `utils` on the spine and
`(root)` emptied of shared contracts, the eleven zones that were unranked
"because ranking them would invent an order the architecture had not committed
to" turned out to have a consistent rank already — the order was there,
unasserted. Solving the constraint system showed one blocker: `utils/remote-config.ts`
projected a remote-config profile into `CliFlags` while reaching up into
`remote/`, and its only three consumers were in `cli/`. It moves there as
`cli/remote-config-flags.ts`, and every satellite zone joins the spine.

Ranked coverage goes from 730/895 files to 882/895. Only `(root)` stays out, and
now for one stated reason: R2 forbids `daemon/` from importing `commands/`, so
the files that wire them compose the spine from above.

Ranking them exposed 22 type-only inversions R6 had never been able to see, and
they were concentrated rather than scattered:

- The device-provider port. `providers/` and `cloud-webdriver/` implement what
  the daemon calls, so both sides name `DeviceLease`, `LeaseLifecycleProvider`,
  `LeaseLifecycleContext` and `DeviceInventoryProvider` — now declared in
  contracts/device-provider.ts, below both. The adapters also imported the
  daemon's NARROWED `DaemonRequest` while only ever reading `req.flags`; they now
  name the public one from kernel/contracts.
- `MetroPrepareKind` and the remote-config profile field groups move to
  contracts/ for the same reason: the command surface validates them and
  contracts/cli-flags.ts is composed from them.

Two clusters remain, ratcheted with their reasons in TYPE_INVERSION_BASELINE:
the client-types vocabulary, and `SessionAction`, which needs `CommandFlags` and
`DaemonBatchStep` to move with it.

Also fixes two things CI caught: the eight type re-exports my earlier import
redirection orphaned (none published through any src/sdk/* entrypoint, so no
public surface changes) and `isSupportedPredicate`, now module-private since
`checkIsPredicate` is the admission API. `fallow-baselines/health.json` is keyed
by path, so the moved cli-config entry moves with the file rather than being
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(selectors): use the admitted predicate, not the raw option

Review finding. `isCommand` called `checkIsPredicate` and then kept reading
`options.predicate` for the capture policy, the `exists` branch,
`evaluateIsPredicate`, the failure message and the returned result. Admission
normalizes case, so an upper-case predicate was let past the gate and then
evaluated against lower-case branches: `EXISTS` skipped its own branch and fell
through to the generic path, and the result echoed the raw token. I widened
admission at that surface without threading the normalized value through it —
the CLI-grammar surface in the same change does use the admitted value.

Every decision after admission now reads it.

Two tests, both verified to fail without the fix:

- a production-route regression driving `device.selectors.is` with
  `EXISTS`/`TEXT`, plus one pinning that an unknown predicate is still refused
  WITH the ADR 0010 usage hint;
- a surface parity gate (selectors/__tests__/is-argument-surface-parity.test.ts)
  in the repo's existing parity style, asserting the daemon and CLI-grammar
  surfaces reach the same verdict and hand the same normalized predicate
  downstream across an input table. A helper-only test cannot catch a surface
  that admits correctly and then discards the result, which is what happened
  here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: name the pre-push gate, and the formatter's path allowlist

Both misses in this PR's review were process, not judgement, and the docs
pointed the wrong way for both.

AGENTS.md said "prefer the aggregate package.json scripts" without naming which
aggregate, and CONTRIBUTING listed `pnpm test` and the targeted checks but never
`pnpm check`. `check:tooling` looks like the gate and is a subset of it: it stops
before the Fallow audit, so the dead exports this PR introduced passed a clean
`check:tooling` and failed CI. Both files now name `pnpm check`, say what it
covers, and say what it cannot (the device matrix).

The same gap produced a second mistake twice: `oxfmt <path>` reformats whatever
you point it at, while the repo's `format` script is an allowlist that excludes
`scripts/` and every `.md`. One run reformatted 50 unrelated script files into a
commit; the next nearly did it to AGENTS.md. AGENTS.md now says to run
`pnpm format`, never `oxfmt <path>`.

It also records the rule that cost a CI cycle: Fallow's baselines are keyed by
path, so a renamed file needs its baseline entry moved, not the baselines
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* revert: undo stray formatter output across docs and scripts

Three separate `oxfmt <path>` runs in this branch reformatted files the repo's
`format` script deliberately excludes: 55 files under scripts/maestro-conformance
plus scripts/perf, sync-mcp-metadata and the slow-test reporter, and 12 markdown
files including six ADRs and docs/agents/. All of it was whitespace, quote style
and markdown table padding — no content — but it inflated the diff a reviewer has
to read and would have rewritten prose ownership across files this change has no
business touching.

All 70 are back to their origin/main content, so the diff outside src/ is now
exactly this change's scope: three docs, scripts/layering, the Fallow baseline,
and five provider integration tests.

The rule this violated is now in AGENTS.md: run `pnpm format`, never
`oxfmt <path>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* style: reformat two provider tests with the repo's pinned oxfmt

`pnpm format:check` failed in CI on the two files whose imports I merged by hand.
The repo pins oxfmt 0.42.0 as a devDependency and both `format` scripts invoke
`./node_modules/oxfmt/bin/oxfmt`; I had reformatted with `npx oxfmt`, which
resolved 0.60.0, and the two versions disagree about wrapping a 100-column import.

This is the rule AGENTS.md already states — run `pnpm format`, never oxfmt
directly — so there is nothing to add to the docs, only to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(ci): install deps for the layering guard, and gate the zero-dep contract

The Layering Guard job failed with ERR_MODULE_NOT_FOUND on `oxc-parser`. The job
ran with `install-deps: false` — no `pnpm install`, so no `node_modules` — and R7
had started parsing the daemon with oxc-parser instead of matching assignment
operators with a regex. `pnpm check:layering` passed on every local run, because
locally `node_modules` is always there.

The job now installs dependencies. The alternative was to put R7 back on a regex,
which cannot see `??=` or a computed `session[key] =` write, so it would trade a
correct rule for a fast job.

That leaves the interesting part: the zero-dep contract is real for the jobs that
keep it, and it is invisible to every local run, which is the worst combination a
constraint can have. R8 makes it checkable. It reads the zero-dep job list out of
`.github/workflows/` rather than restating it — declaring a job zero-dep is what
puts it under the rule — walks each job's entry scripts and their whole
relative-import closure, and requires every specifier to be a Node builtin or
another repo file. A zero-dep job whose entry scripts the scan cannot identify
fails too, so the rule cannot be escaped by changing how the job invokes them.

Specifiers come from oxc-parser's module record, not a line scan. The closures
include `--test` files, and a test about imports naturally embeds import syntax in
a fixture string; the line scanner reported two such phantom violations in
model.test.ts before the switch, which is how a gate stops being trusted.

Verified by re-running the real gate against three injected regressions: the
layering job back on `install-deps: false` (reproduces the exact CI failure,
pointing at session-state.ts:24), a package import added to the still-zero-dep
affected-selector closure, and a zero-dep job whose run step names no script.

Also corrects the CONTEXT.md spine paragraph, which still described the satellite
zones as deliberately unranked after they had all joined the ranked spine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(layering): make R7 exhaustive, and follow session records through aliases

Review finding: `SESSION_STATE_FIELD_OWNERS` covered 27 of `SessionState`'s 42
fields and nothing asserted parity, so a new field could be added and pass the
gate by being invisible to it. R7's advertised claim — "every SessionState write
is inside its declared owner" — was broader than what it checked.

Investigating that turned up a second, larger gap the finding did not name: the
scan only recognized a binding literally named `session`. The daemon names these
records by role, so `nextSession`, `provisionalSession`, `completedSession`,
`preRunSession` and `preEntrySession` were all invisible — and three of those
writes were genuine violations R7 existed to catch:

  src/daemon/snapshot-runtime.ts:256  nextSession.snapshotScopeSource
  src/daemon/snapshot-runtime.ts:265  nextSession.snapshotGeneration
  src/daemon/handlers/session-replay-runtime.ts:707
                                      preEntrySession.pendingRecordAndHeal

The first two are the #1076 versioned-ref invariant: the generation advances
exactly when the stored tree is replaced. That rule lived in `setSessionSnapshot`
and had acquired a second statement of itself in snapshot-runtime.ts, whose own
comment admitted the bypass. It now goes through `setSnapshotLineage` in the
owning module. The third clears a watermark stamped by session-replay-resume.ts;
`clearPendingRecordAndHealWatermark` puts the clear beside the stamp.

Gate changes:
- Binding detection accepts aliases, paired with the existing declared-field
  filter so an unrelated `…Session` local only registers if it also writes a
  field SessionState owns — where the remedy is the same anyway.
- `fieldClassificationDrift` asserts parity in all three directions:
  unclassified, in-both, and naming a field SessionState no longer declares.
- `STORE_OWNED_SESSION_STATE_FIELDS` classifies the 11 fields the store
  establishes at construction. It is a positive claim, so a direct write to one
  fails and names both remedies.
- Four fields the widened scan made visible (`lease`, `deviceClaim`, `appName`,
  `saveScriptComplete`) got real owners.

`nextSnapshotGeneration` is now module-private: replacing its only external call
site orphaned the export, which `pnpm check` caught via Fallow.

Verified against three injected regressions: a new SessionState field with no
direct write (the reviewer's exact scenario), a foreign write through an alias
binding, and a direct write to a store-established field. All three rejected.
`pnpm check` green, 4486 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs(daemon): correct the snapshot-lineage claim, and pin the real contract

Device verification of the snapshot-lineage route found that a ref pinned before
a `diff` keeps resolving with no pinned-ref warning. That is the designed ADR
0014 behaviour, not a regression — the comment describing it was wrong, and I
propagated it.

`main`'s comment in snapshot-runtime.ts said a diff "leaves client refs pinned to
the previous generation, which is exactly what the pinned warning diagnoses". The
counter and the authorization epoch are different clocks:

  - `diff` passes `issuesRefsToClient: false`, so it never reactivates the frame;
  - `resolveRefStalenessWarning` compares a pin against the frame EPOCH, not the
    observation counter, and its own comment says why — a capture that bumped the
    counter must not make a valid pin from the issuing frame look stale.

So advancing the counter is not the same as invalidating client refs, and the
observable the comment promised does not exist. I carried the sentence into
`setSnapshotLineage`'s doc when the transition moved, and then into a hardware
verification request, which cost a reviewer a device run against a false claim.

`setSnapshotLineage` itself is unchanged and was a pure move: same expressions,
same inputs as the inline assignments it replaced, so this route behaves exactly
as it does on main.

A comment that contradicts the code should be an assertion instead, so the
contract is now pinned in session-snapshot.test.ts: the diff advances the counter,
preserves the epoch, leaves the pre-diff pin resolving without a warning, and
still warns for a pin from a different frame. Verified to fail when the epoch
comparison is swapped for the counter. A second test covers the keep-current
branch, which had no coverage.

`pnpm check` green, 4488 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 08:08:34 +02:00

15 KiB

Dependency graph findings

Snapshot analysis of the production import graph — 894 files, 4619 edges, 25 zones — taken while doing the boundary work in this branch. It is a dated observation, not a normative document; when it disagrees with scripts/layering/, the gate wins.

The graph tool that produced it lives on the claude/depgraph-viewer branch (pnpm depgraph), deliberately kept out of this change so the refactor stands on its own.

Where this round landed

before after
type-only spine inversions (R6) 61 across 6 zone pairs 35 across 4, ratcheted
files in the unranked (root) zone 29 13 — entrypoints and composition roots only
files covered by the ranked spine 651 of 892 729 of 894
type imports pointing at a re-export hub in another zone 89 0
selector-command rules stated in both zones 10 messages, 3 drifts 0 — shared in selectors/
modules writing ADR 0014's four ref-frame fields 2 1, enforced by R7
value-import cycles (R4) / spine back-edges (R5) 0 / 0 0 / 0

What moved: the platform-plugin contract and its four facet tags, NetworkEntry, the click-button / recording-export-quality / interactor-types / runner-lease-context vocabularies, and 16 internal modules out of (root); utils joined the spine at rank 1 after its two upward files moved to the zones they were reaching for. Three new gate scopes keep it: R6 ratchets type-only inversions, R7 pins SessionState field ownership, and the shared selector checks in selectors/ are covered by their own tests.

What the gate guarantees, and what it cannot see

  • 0 production value-import cycles (R4), 0 spine back-edges (R5), and type-only inversions pinned by the R6 ratchet.
  • Internal barrels are effectively gone. One re-export-only file remains (sdk/index.ts, 4 lines); sdk/ totals 79 lines across 11 files — the legacy alias surface CONTEXT.md already gates for the next major.
  • The ADR 0017 parameterization boundary is exactly where the ADR says it is: daemon/parameterized-recorded-fill.ts has precisely two dependents — the response boundary (handlers/interaction-common.ts, step 3) and the recorder boundary (session-action-recorder.ts, step 4). The two-pass structure is two call sites, not a scattered concern.
  • Still outside every rule: dynamic import direction (0 inversions today, nothing watching), and anything inside a zone.

1. The two remaining type-inversion clusters

TYPE_INVERSION_BASELINE in scripts/layering/check.ts holds both, with the reasoning inline.

28 + 1 edges → client/client-types.ts (1236 lines). The file does three jobs: the public Node-client facade (AgentDeviceClient, config, transport), the per-command *Options/*Result vocabulary that commands/, contracts/ and mcp/ all need, and a re-export hub for 24 contracts/ types. Only the first is client-owned. The fix is the pattern the file already uses for those 24 re-exports: declare the command I/O vocabulary in contracts/ and re-export it here, so the public surface stays byte-identical. Note the coupling is mutual — client-types imports commands/interaction/runtime/gestures.ts and commands/system/navigation-projection.ts — which is what closes the 5-node type cycle in §4. This is also the deferred "Node client result types" work CONTEXT.md already tracks.

5 + 1 edges → daemon/daemon-command-registry.ts and daemon/types.ts. core's descriptor registry composes the ADR 0003 daemon facet, whose shape the daemon declares. ADR 0003's daemon-owned-declaration invariant is about the values (route + policy traits), which stay in daemon/; only the shape needs to sit below core. DaemonCommandDescriptor references the daemon-internal DaemonRequest, so this is a real change, not a file move — either the facet type becomes generic over the request type, or the request shape itself moves down.

2. daemon/types.ts is a second contracts module at rank 4

554 lines, 174 dependents, 173 of them type-only, and 10 of its 15 exported types are imported from outside daemon/: DaemonRequest (8), SessionAction (7), ReplaySuiteResult (6), ReplaySuiteTestResult (4), DaemonResponse (2), plus SessionRuntimeHints, DaemonInvokeFn, DaemonResponseData, DaemonArtifact, DaemonLockPolicy. Those ten belong in contracts/ — and moving DaemonRequest is most of what §1's second cluster needs.

3. (root): what is left is there for a reason

13 files: 11 published package.json entrypoints, the three executables (bin.ts, cli.ts, daemon.ts), and the composition roots — runtime.ts, agent-device-client.ts, provider-device-runtime.ts, command-catalog.ts.

The composition roots cannot join the spine, and the reason is worth recording: R2 (commands-floor) forbids daemon/ from importing commands/, so anything that wires the command surface into the daemon must sit outside the spine. runtime.ts imports commands/index.ts and is consumed by five daemon files; that is exactly what UNRANKED_ZONES' "compose the spine from above" means. Two remain awkward:

  • command-catalog.ts (42 lines, 45 dependents in 7 zones) is a pure projection of core/command-descriptor/registry.ts, so core/ is its natural home — but platforms/apple/plugin.ts and platforms/vega/plugin.ts import PUBLIC_COMMANDS to key their Record<string, (device) => boolean> default-support maps, and platforms is rank 1. Moving the catalog to core/ would create two real back-edges. The ADR-0008-aligned fix is the other direction: those per-command support maps are the descriptor's capability facet, so the platform plugins should not be naming commands at all.
  • provider-device-runtime.ts (338 lines) is imported by core/interactors.ts at value level and itself needs daemon/lease-registry.ts and daemon/handlers/lease.ts at value level. It is a genuine composition root; ranking it would require splitting the provider lookup from the lease wiring.

4. Eight cycles the gate does not reject — seven type-only, one dynamic

R4 covers static value edges only, by design.

[type]    backend.ts -> commands/command-input.ts -> client/client-types.ts
          -> commands/interaction/runtime/gestures.ts -> runtime-contract.ts -> backend.ts
[type]    cloud-webdriver/aws-device-farm-artifacts.ts <-> cloud-webdriver/aws-device-farm.ts
[type]    cloud-webdriver/capabilities.ts <-> cloud-webdriver/runtime.ts
[type]    commands/batch/index.ts -> commands/batch/projection.ts
          -> commands/command-projection.ts -> commands/batch/index.ts
[type]    daemon/device-claim-inspection.ts <-> daemon/device-claims.ts
[type]    platforms/android/adb-executor.ts <-> platforms/android/snapshot-helper-types.ts
[type]    platforms/web/agent-browser-lifecycle.ts <-> agent-browser-tool.ts
[dynamic] platforms/apple/os/macos/audio-probe.ts <-> platforms/audio-probe-backend.ts

The five mutual pairs are all the same shape — two modules co-defining one contract — and all have the same one-line fix: a third module holding the shared type. The 5-node loop closes through backend.ts (root, 21 type-only dependents across 5 zones) reaching up into commands/command-input.ts; it will not close once §1's first cluster is done.

5. daemon-server: where the size actually comes from

219 files / 46 550 lines — 25% of src/, at rank 4. daemon/handlers/ is 102 files / 24 292 lines in one flat directory whose names carry a hierarchy the filesystem does not: 61 session-* (14 723 lines), 16 interaction-*, 13 record-*, 5 snapshot-*. daemon/ itself is another 103 flat files.

Things that are not wrong, checked and ruled out:

  • No copy-paste. Zero 7-line windows repeat across three or more daemon files.
  • The two error conventions converge. 202 errorResponse(...) sites sit beside 64 thrown AppErrors, and a probe through the real router confirmed both reach the wire with the same hint, diagnosticId and logPath — finalizeDaemonResponse rebuilds every returned failure into a fresh AppError and re-normalizes it. The cost is that shim (whose own comment records a bug where it dropped retriable/supportedOn) and the Response | Failure unions it forces through every helper signature, not a behavioural gap. They are not interchangeable, though: the same probe showed the returned and thrown paths write different session events, so converting one to the other is a behaviour change, not a cleanup.
  • The ADR 0008 projection landed. DAEMON_COMMAND_DESCRIPTORS is derived from the core descriptors; the "copied VERBATIM from daemon" comment in core/command-descriptor/registry.ts is a stale migration note, not live duplication.

5a. SessionState is store-owned mutable state — now with declared owners

SessionStore.get() returns the live object out of a private Map; set() re-puts the same reference. So the 57 direct session.<field> = … writes across 17 files are durable whether or not set() follows, which makes the 26 set() calls ceremonial — they document intent rather than committing anything. Both methods now say so in their own doc comments.

Measuring which module writes which field showed the problem is narrower than the raw count: 16 of 27 fields already have exactly one writer. The sharp case was ADR 0014's ref frame — refFrameState, refFrameScope, refFrameTree, refFrameGeneration must move together or the frame is incoherent, yet complete issuance wrote them in ref-frame.ts and partial issuance wrote the same four in session-snapshot.ts, even though ref-frame.ts claims in its header to be "the single owner of the frame's transitions". Both forms now go through activateRefFrame.

recordSession deliberately moves alone in two paths (recording without arming a publication), so the save-script cluster got no invented abstraction. It got ownership: R7 records every field's owner in SESSION_STATE_FIELD_OWNERS and stops the set growing quietly — a new field must declare an owner, a foreign write fails naming the owner to call, and an owner that stops writing must be removed so the table cannot drift into fiction.

5b. 88 platform-conditional sites, in the layer ADR 0009 exists to keep neutral

daemon-server holds 88 platform === '…' / switch (platform) / isApple*() sites — more than platforms/ itself (48) — against a plugin contract whose own doc says "The plugin's only job is to stop core/daemon from BRANCHING on platform." Four facets are live (appLog, recording, perf, provider gating); the branch count says the mechanism is right and unfinished. Worst offenders: handlers/session-perf.ts (12), handlers/session-doctor-device.ts (6), handlers/session-replay-maestro-runtime.ts (6), handlers/session-state.ts (5). Each is a candidate facet, and each facet retired is a branch deleted in every command that shares it.

5c. Redundant edges: hub concentration, not debt

1333 of 3147 value edges (42%) are transitively redundant, 1115 through a single intermediate hop. This is not a defect list — importing kernel/errors.ts directly is clearer than inheriting it through a sibling. It earns its keep per file: daemon/server/daemon-runtime.ts gets 18 of its 32 imports from one neighbour, handlers/session-open.ts 17 of 30, handlers/session.ts 17 of 32, handlers/find.ts 16 of 20. A file whose neighbour already provides two-thirds of what it imports is usually doing its neighbour's job too — the same orchestrator smell as §5, from the other side.

6. R2 is right, and the duplication it forces now has a home

R2 (commands-floor) forbids kernel/platforms/core/daemon from importing commands/. Measured, that is not an arbitrary restriction but the shape of the system: commands/ is consumed only by cli/ (9), cli-schema/ (9), mcp/ (16), client/ (2) and the composition roots (7). It is the client-side surface; the daemon is the executor on the other side of the wire, and ADR 0008 protects exactly that seam ("the process boundary is never collapsed"). Relaxing R2 would let the executor depend on a client projection and pull CLI grammar and output formatting into the daemon's bundle.

The duplication is real, though, because the daemon must validate independently — it accepts requests from any client — so the only place a shared rule can live is below both zones. selectors/ already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs, isSupportedPredicate) and even the is predicate message; it just did not hold the checks that use them. It does now: checkFindArgs, checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs, checkWaitText. Each reports a refusal and leaves the mechanism to the caller, because returning and throwing are not interchangeable — they write different session events.

Three drifts had already appeared in the is predicate rule alone, which is the argument for doing this rather than leaving the copies aligned by hand:

  • commands/interaction/selectors.ts re-implemented the predicate list as an inlined seven-way !== chain while importing the message and hint from selectors/predicates.ts, so adding a predicate to the shared list would not have reached the CLI grammar.
  • That chain compared the raw token, so the CLI rejected is TEXT … while the daemon it hands the command to accepts it.
  • isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether an agent got recovery guidance depended on which layer noticed first — the failure mode ADR 0010's audit calls out.

Still duplicated across zones, each needing the same treatment: fill requires text / type requires text (commands/interaction/runtime/interactions.ts ↔ core/dispatch-interactions.ts), open <app> <url> requires a valid URL target and settings clear-app-state requires an app id… (core/dispatch.ts ↔ two platform modules), and snapshot is not supported by this backend (three files inside commands/).

Suggested order from here

  1. Move the 10 outward-facing daemon/types.ts types into contracts/ (§2). Mechanical, and it clears most of §1's second cluster.
  2. Split client/client-types.ts (§1). Largest remaining inversion cluster, closes the 5-node type cycle, and it is already-tracked deferred work.
  3. Retire platform branches into plugin facets (§5b), highest-count files first.
  4. Share the remaining duplicated validators (§6), following the checkIsArgs shape.
  5. Optional: give daemon/handlers/ the directory structure its filenames already imply (§5).