* refactor(contracts): declare the public API vocabulary below its consumers
The layering gate's largest remaining cluster was 28 type-only inversions from a
single edge: `commands/` declaring itself in terms of `client/client-types.ts`.
R2 forbids the reverse import, so a shape both surfaces need has to sit below
both. The command/device vocabulary — connection config, the device and session
views, and every per-command Options/Result — now lives in
`contracts/client-api.ts`; `client/client-types.ts` keeps the `AgentDeviceClient`
facade and re-exports the rest through one wildcard.
R6 total: 42 -> 18. No new inversion in any pair.
The published surface is unchanged, and that is verified rather than asserted:
the built `index.d.ts` exports the same 216 type names as main, byte-identical.
Eight shapes deliberately did NOT move, because each is stated in terms of a
HIGHER-ranked zone: `ScrollOptions` (ScrollInputDirection, commands/), the four
navigation Options plus `AgentDeviceCommandClient` (navigation-projection,
commands/), and the two Metro result aliases (metro/). Declaring those in
contracts/ would trade 28 commands->client edges for contracts->commands and
contracts->metro ones — the foundation depending on the layers above it, worse in
kind even though fewer in number. This is measured, not assumed: moving the whole
file to contracts/ first took the gate from 42 to 48, which is how the floor was
found.
Two keystone moves made the other 84 movable:
- `RemoteConnectionProfileFields` joined its sibling `CloudProviderProfileFields`
in contracts/remote-config-fields.ts. It was the root of the base chain
(AgentDeviceClientConfig -> AgentDeviceRequestOverrides ->
DeviceCommandBaseOptions -> every per-command Options), so one rank-4
declaration was pinning ~80 shapes up with it.
- `DaemonBatchStep` moved to contracts/batch-step.ts. Its `runtime` field was
written `DaemonRequest['runtime']`, dragging the whole daemon request type in to
say `SessionRuntimeHints` — the same type, three zones lower.
`CompanionTunnelScope`/`MetroBridgeScope` also moved to contracts/, since the
vocabulary needs the scope shape and it sat next to client-local env-var names.
Six pass-through re-exports in client-types.ts are suppressed per-name with the
reason inline: they exist only to publish contracts/kernel types through the
package entrypoint wildcard, every internal consumer imports them from the
declaring module, so "no consumer" is correct and not actionable — deleting them
would remove names from the public types.
`pnpm check` green, 4488 unit tests. Findings doc records the sequencing for the
last 5: the upstream declarations have to come down before the shapes that need
them can.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* docs: drop the graph viewer, keep the query that replaces it
The rendered dependency-graph viewer is not being merged (PR #1409 closed). It cost
~2200 lines plus a Fallow exemption for a 920-line canvas renderer, and nobody —
human or agent — reached a conclusion from the picture. Every finding in this
document came from short queries against the gate's own model.
This file pointed at the `claude/depgraph-viewer` branch for the tooling, which
would have dangled once that branch is deleted. Replaced with the thing that was
actually load-bearing: a throwaway probe script, inlined, that re-derives the
numbers from `scripts/layering/model.ts` and nothing else. Verified verbatim — it
reproduces TYPE_INVERSION_BASELINE exactly, which is also the check that tells you
whether either side has gone stale.
Two numbers in the summary table were stale, describing an intermediate state
rather than what shipped: R6 said "35 across 4" (actually 18 across 5 after the
vocabulary move) and ranked coverage said "729 of 894" (actually 888 of 901). Both
corrected, along with the file/edge counts in the header.
Also notes the deduplication detail that makes the query agree with the gate: each
file pair counts once, so a raw edge count reads higher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* refactor(contracts): move the four keystones that pinned the rest of the inversions
R6 type-only spine inversions: 18 -> 7, and every one of the 7 that remains is a
deliberate architectural position rather than a misplaced declaration.
Four keystones moved to contracts/, each of which was pinning a much larger set:
- `CommandFlags` (was core/dispatch-context.ts). One rank-2 declaration holding the
daemon's request type and every recorded action above it. Its last non-contracts
dependency was `DaemonBatchStep`, already moved in 3fdbfe0.
- `SessionAction` (was daemon/types.ts). replay/ (6 modules) and compat/maestro/
read and write session scripts; declaring the shape inside the daemon made both
depend on the server to describe a file format neither asks it to produce. The
daemon still owns the recording — only the shape moved.
- `TargetAnnotationV1` shape (was replay/target-identity.ts). ADR 0012 target
evidence, written by 8 daemon modules and read by commands/; the parsing and
classification logic stays in replay/.
- `ScrollInputDirection` and the Metro prepare/reload result payloads, which
unblocked `ScrollOptions` and `MetroPrepareResult`/`MetroReloadResult`.
`DaemonRequest` also split into the three shapes it had been conflating: the
kernel WIRE shape (`flags?: Record<string, unknown>`, because a process boundary
cannot enforce a vocabulary), the new `contracts/command-request.ts`
`CommandRequest` (wire shape with flags typed — what a command surface needs), and
the daemon's own refinement (+ `internal?: DaemonRequestInternal`, carrying
SessionState callbacks and the admitted lease). core/command-descriptor/ had been
importing the third to read `command`, `positionals` and `flags`.
Two things deliberately NOT moved, because moving them would add coupling rather
than remove it, and the baseline now argues both:
- `DaemonCommandDescriptor`/`DaemonCommandRoute` — the route type is
`keyof typeof DAEMON_ROUTE_HANDLERS`, derived from what the server implements.
Moving it down means re-declaring route names in contracts plus a gate to prove
the handler map still covers them. ADR 0003/0008 own that boundary.
- `AgentDeviceClient` — used as an opaque handle by 4 files. The facade is built
from commands/'s own NAVIGATION_COMMAND_PROJECTIONS, so this is a genuine
zone-level cycle; breaking it is a design call about where that registry belongs.
R5 is zero here: nothing imports the client at runtime, only its type.
Also records the largest structural finding, which R6 does not measure: cycles by
edge kind are 1 (value only), 87 (value + type-only), 1 (value + dynamic), 213
(all). At runtime the graph is a clean DAG; the 87-file type-level cluster means
no one of those files' types can be read in isolation. Hubs are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts. Not attempted here — it is a different and much larger
change.
`pnpm check` green, 4488 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* feat(layering): ratchet type-cycle growth (R9), and rule out a narrower client port
R9: the largest strongly-connected component over value + type-only edges may not
grow. R4 keeps the VALUE graph acyclic, so every cycle counted here is created by
type-only imports - free at runtime, invisible to R5/R6, and the largest single
obstacle to reading a subsystem in isolation: inside a component of 102 files, no
file has a self-contained slice.
Baseline set to 102, which is what THIS branch achieves - main carries 107 and the
boundary moves here bring it to 102. An earlier revision baselined 87, measured
against an older main; after rebasing onto f19864e the real figure was 102 and the
new rule fired on its own stale baseline. Worth stating because the failure looked
like a regression and was not: attribution showed main at 107 and this branch
reducing it, which is the check working rather than complaining.
Growth-only, deliberately unlike R6. Reducing 102 is a real refactor rather than a
file move, so a hard equality would turn every unrelated improvement into a baseline
edit. A shrunk tree is reported in the success line instead of failing. Verified at
the new baseline by adding one type-only import that closes a loop and watching 102
become 108 and the gate reject it.
The refactor itself is still not attempted. Hubs by in-component dependents are
runtime-contract.ts, commands/runtime-types.ts, backend.ts,
commands/runtime-common.ts; a pass starts there.
Separately, investigated the narrower-port idea for the 4 remaining -> client
inversions and it does not work. Measured first:
files NAMING AgentDeviceClient (the inversions) 4
files CALLING client methods 26
distinct facade namespaces reached 13
The narrowness is an artifact of where the type is named, not of what is used.
Making those four generic over the client type pushes the concrete type into the 26
implementations, turning 4 inversions into up to 26. A port spanning 13 namespaces
is the whole facade, so it would either duplicate the public API shape - a second
source of truth for it - or derive from the facade and carry the same dependency.
So the four are the minimum number of naming sites rather than an accident: they are
the choke point. Recorded as a position with the numbers behind it. The remaining
option is the question underneath it - whether NAVIGATION_COMMAND_PROJECTIONS
belongs in commands/ - and that is a design decision about the command surface, not
a dependency cleanup.
pnpm check green, 4535 unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* fix(layering): test R9, specify its floor, and drop two duplications
Adversarial self-review of #1435 found four things worth fixing.
R9 shipped with no unit test. Every other rule in this gate has one (R5 back-edges,
R6 inversions, R7 session state, R8 zero-dep closures); R9's only verification was a
manual injection CI cannot repeat. Added tests for the three distinctions it depends
on: a type-only loop counts, a dynamic-only loop does not, a value loop still does.
Writing that test immediately found an undocumented edge case, which is the argument
for it. largestTypeCycleSize returns 1 for an acyclic graph that has non-dynamic
edges but 0 when every edge is dynamic, because only edge-participating files enter
the walk. Immaterial to a growth ratchet, but an inconsistent floor nobody had
written down. Now specified in the doc comment and pinned by the test, so 0 and 1
cannot later be read as a meaningful difference.
largestTypeCycleMembers was exported with no consumer - speculative API, and
scripts/layering is in Fallow's ignorePatterns so nothing would have flagged it.
Same pattern review caught on the previous head with MaestroRuntimeFlags and
TargetRect. Made module-private.
ResolvedMetroKind was declared twice after the Metro payload move: exported from
contracts/metro.ts and still private in metro/client-metro.ts. client-metro.ts now
imports it.
The gate computed the SCC twice per run, once in the rule and once for the success
line. Computed once and threaded, so the two can no longer disagree.
Also re-verified the claim this PR rests on, with a stronger check than the one in
the body: comparing DECLARATION names in index.d.ts counts inlined internals, and by
that measure this branch appears to lose five names (PrepareMetroRuntimeResult,
ReloadMetroResult, ResolvedMetroKind, SCROLL_INPUT_DIRECTIONS, ScrollInputDirection).
All five are declared-but-not-exported helpers. The real surface - exported names
across all eleven published entrypoints - is 69 on both sides, identical. Also proved
DaemonRequest structurally equal to its pre-split shape with a type-level assertion
rather than by reasoning, and confirmed SessionAction, CommandFlags and
TargetAnnotationV1 moved byte-identically.
pnpm check green, 4535 unit tests, 24 layering tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
* refactor(contracts): one file per command family, one name per request
Addresses review on #1435.
contracts/client-api.ts was 1,064 LOC and grouped session, app, interaction,
replay, observability and recording contracts together, so it answered no one
question and crossed the >1,000-LOC architecture-debt tripwire in AGENTS.md:124.
Split it into 14 domain-family files by the command families that already exist
(client-connection, client-device-view, client-session, client-lease, client-app,
client-capture, client-target, client-gesture, client-selector-read,
client-replay, client-observability, client-settings, client-system,
client-request); the four Metro client shapes went into the existing
contracts/metro.ts so one file answers the Metro question. Largest resulting
file is 137 LOC. client/client-types.ts re-exports one wildcard per family, so
the published import path is unchanged.
Published surface verified unchanged against main two ways: the exported-name
set of all 11 published entrypoints is identical (70 names), and every
declaration in the built index.d.ts is byte-identical after normalization -- 0
names added, 0 shapes changed. index.d.ts got smaller (1,726 -> 1,682 lines):
10 declarations main duplicated into it now resolve through a shared chunk.
Also, from re-examining the two findings the review flagged as blind spots:
- CommandRequest was a third name for "a request" that no consumer needed.
Every core/command-descriptor/ use read only command/positionals/flags, in two
spellings (the full type and a Pick of it). Replaced by
contracts/dispatched-command.ts DispatchedCommand -- those three fields and
nothing else, with command/positionals Picked from the wire type so they
cannot drift. daemon/types.ts DaemonRequest now extends the wire shape
directly. Two request shapes again, at two ranks.
- The 7 remaining R6 inversions each get a mechanical reason rather than an
appeal to an ADR: the 4 AgentDeviceClient edges are a real zone-level cycle
(client-types.ts imports ProjectedNavigationCommandClient from commands/), and
no narrower port exists (26 call sites across 13 namespaces); the 2
DaemonCommandDescriptor edges are unavoidable because that shape is stated in
terms of the server-private DaemonRequest; the 1 DaemonCommandRoute edge is
unavoidable because the type is computed from the daemon's handler table.
Cleanups found on the way: three doc comments this branch had orphaned from
their declarations (SettleCommandOptions, RecordControlOptions,
ReloadMetroResult -- the last had drifted onto an unrelated type it
misdescribed) are reattached; intra-contracts imports normalized from
'../contracts/x.ts' to './x.ts', which is what the duplicate-import lint caught;
and stale references to the deleted file removed from the docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
---------
Co-authored-by: Claude <noreply@anthropic.com>
26 KiB
Dependency graph findings
Snapshot analysis of the production import graph — 918 files, 25 zones — taken while doing the
boundary work across #1405 and its follow-ups. It is a dated observation, not a normative
document; when it disagrees with scripts/layering/, the gate wins.
Two ways to reproduce any number below. pnpm depgraph (#1410) emits the whole graph as JSON plus a
summary, and is the tool to reach for when you want the reachability or cycle analysis it computes. A
rendered viewer was also built and dropped — ~2200 lines and a Fallow exemption for a picture
neither a human nor an agent drew a conclusion from; the analysis survived, the rendering did not.
For a one-off question, a throwaway probe against the gate's own model is faster and leaves nothing to clean up:
// scripts/layering/.probe.ts (throwaway; the gate's model is the only dependency)
import fs from 'node:fs';
import { listSourceFiles } from './check.ts';
import { resolveImportEdges, typeInversionPair, backEdgePair } from './model.ts';
const files = listSourceFiles();
const sources = new Map(files.map((f) => [f, fs.readFileSync(f, 'utf8')]));
const edges = resolveImportEdges(sources);
// e.g. R6 inversions per zone pair, deduplicated by file pair — reproduces
// TYPE_INVERSION_BASELINE, so a mismatch means one of the two is stale.
const seen = new Set<string>();
const byPair = new Map<string, number>();
for (const edge of edges) {
const pair = typeInversionPair(edge);
if (!pair) continue;
const id = `${edge.file} -> ${edge.target}`;
if (seen.has(id)) continue;
seen.add(id);
byPair.set(pair, (byPair.get(pair) ?? 0) + 1);
}
console.log([...byPair].sort((a, b) => b[1] - a[1]));
Run with node --experimental-strip-types scripts/layering/.probe.ts and delete it after. Swap
typeInversionPair for backEdgePair for R5, or group edges by fromZone/toZone for
zone-level traffic. Deduplicating by file pair matters: the gate counts each file pair once, so a
raw edge count reads higher.
Where this round landed
| before | after | |
|---|---|---|
| type-only spine inversions (R6) | 61 across 6 zone pairs | 7 across 4, ratcheted |
files in the unranked (root) zone |
29 | 13 — entrypoints and composition roots only |
| files covered by the ranked spine | 713 of 898 | 888 of 901 |
| 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 surfaceCONTEXT.mdalready gates for the next major. - The ADR 0017 parameterization boundary is exactly where the ADR says it is:
daemon/parameterized-recorded-fill.tshas 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.
0. Where the inversions ended up (and why 7 is the floor for now)
61 → 7. The last pass moved four keystones, each of which was pinning a much larger set:
Keystone moved to contracts/ |
Unblocked |
|---|---|
DaemonBatchStep (was core/batch.ts, typed via DaemonRequest['runtime']) |
CommandFlags |
CommandFlags (was core/dispatch-context.ts) |
SessionAction, DispatchedCommand |
TargetAnnotationV1 shape (was replay/target-identity.ts) |
SessionAction |
ScrollInputDirection, Metro result payloads |
ScrollOptions, MetroPrepareResult/MetroReloadResult |
Two of those deserve their own note, because the pattern repeats: DaemonBatchStep's runtime field
was written DaemonRequest['runtime'], pulling the whole daemon request type in to say
SessionRuntimeHints — the same type three zones lower. And CommandFlags was a single rank-2
declaration pinning ~80 public API shapes above it. Neither looked like a keystone from the graph;
both were found by asking "what does the target itself import, and what rank is that?"
DaemonRequest had been conflating a request with the command that request dispatches. There are
still only two request shapes — and that is the point, because a third would be a third name for the
same thing:
kernel/contracts.tsDaemonRequest— the wire shape,flags?: Record<string, unknown>, because a process boundary cannot enforce a flag vocabulary;daemon/types.tsDaemonRequest— the wire shape withtoken/sessionrequired,flagsnarrowed toCommandFlags, andinternal?: DaemonRequestInternalcarryingSessionStatecallbacks and the admitted lease. Server-private, and why it cannot move down.
core/command-descriptor/ had been importing the second to read command, positionals and
flags — reaching up two ranks for three fields. It now takes
contracts/dispatched-command.ts DispatchedCommand, which is those three fields and nothing else,
with command/positionals Picked from the wire so they cannot drift from it. Every descriptor
resolver already read only those three, in two spellings (the full type and a Pick of it); one
narrow name replaced both.
The remaining 7 are positions, not debt — each for a mechanical reason, not an appeal to an ADR:
- 4 ×
AgentDeviceClient(commands/command-contract.ts,commands/command-surface.ts,commands/family/types.ts,mcp/command-tools.ts). The facade cannot move belowcommands/because it is built from the command surface:client/client-types.tsimportsProjectedNavigationCommandClientfromcommands/system/navigation-projection.ts. That is a real zone-level type cycle, and breaking it means deciding where the projection registry belongs — a design call, not a file move. A narrower port does not exist either: 4 files name the facade, but 26 call sites use methods across 13 of its namespaces, so any port would re-declare it. - 2 ×
DaemonCommandDescriptor(core/command-descriptor/derive.ts,.../types.ts). It is stated in terms of the server-privatedaemon/types.tsDaemonRequest—refFrameEffect?: (req: DaemonRequest) => RefFrameEffect,allowSessionlessDefaultDevice?: (req: DaemonRequest) => boolean— so it cannot be declared below the daemon. Havingcore/re-declare a parallel 13-field shape instead would trade one erased edge for a second source of truth. - 1 ×
DaemonCommandRoute(commands/command-explain.ts). It iskeyof typeof DAEMON_ROUTE_HANDLERS— computed from the daemon's handler table, so it cannot exist below that table.command-explain.tsuses it to key an exhaustiveRecord<DaemonCommandRoute, string>of owner files; a hand-written union incontracts/would drop exactly that exhaustiveness.
All three are argued at TYPE_INVERSION_BASELINE in scripts/layering/check.ts, next to the
numbers they explain.
0b. The biggest structural finding is not an inversion
Cycle size by edge kind, measured over the whole production graph:
| edges considered | largest strongly-connected component |
|---|---|
| value only | 1 — no cycles, which is what R4 enforces |
| value + type-only | 102 files |
| value + dynamic | 1 |
| all kinds | 213 files |
At runtime the module graph is a clean DAG. The 102-file cluster is purely type-level: you cannot
read the types of any one of those files without transitively reaching all 102. (main carries 107;
the boundary moves in this branch bring it to 102.) That is not a correctness
problem — types are erased — but it is a comprehension one, and it is the single largest obstacle to
reading a subsystem in isolation. It spans commands (33), daemon (21), platforms (13), core
(12), hubs at runtime-contract.ts (25 in-cluster dependents), commands/runtime-types.ts (21),
backend.ts (15), commands/runtime-common.ts (12).
Now ratcheted for growth by R9 (TYPE_CYCLE_BASELINE in check.ts), so it cannot get worse
while nobody is looking — a type-only import that closes a new loop fails the gate, verified by
adding one type-only import that closes a loop and watching the gate reject it. Growth-only on
purpose: reducing it is a real refactor, so a
hard equality would turn every unrelated improvement into a baseline edit. The refactor itself is
still deliberately not attempted; it starts at those four hubs.
The facade cycle: investigated, no narrower port exists
The 4 remaining -> client inversions are AgentDeviceClient used as an opaque handle. The obvious
fix is a narrower port in contracts/ describing only what commands/ needs, with client/
satisfying it. Measured before attempting it:
| count | |
|---|---|
Files naming AgentDeviceClient (i.e. the inversions) |
4 |
| Files calling client methods | 26 |
| Distinct facade namespaces reached | 13 |
The narrowness is an artifact of where the type is named, not of what is used. Making the four generic over the client type pushes the concrete type down into the 26 implementations, turning 4 inversions into up to 26. A port covering 13 namespaces is the whole facade, so it would either duplicate the public API shape — a second source of truth for it — or derive from the facade and carry the same dependency.
Those four files are therefore the minimum number of naming sites, not an accident: they are the
choke point. Accepted as a position, argued at TYPE_INVERSION_BASELINE. The remaining option is
the one that was always the real question — whether NAVIGATION_COMMAND_PROJECTIONS belongs in
commands/ — and that is a design decision about the command surface, not a dependency cleanup.
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 — done, mostly. Now 5 edges. The vocabulary moved into
the contracts/client-*.ts family files — one file per command/domain family, largest 137 LOC —
with client/client-types.ts keeping the AgentDeviceClient facade and re-exporting the rest
through one wildcard per family. The published surface is unchanged, verified two ways against
main: the exported-name set of all 11 published entrypoints is identical (70 names), and every
declaration in the built index.d.ts is byte-identical after normalization (0 names added, 0 shapes
changed). index.d.ts in fact got smaller — 1,726 → 1,682 lines — because 10 declarations that
main duplicated into it (the Metro option/result shapes, ScrollInputDirection) now resolve
through a shared chunk once the vocabulary sits below both its consumers.
The mutual coupling this section already warned about is what set the floor. Eight shapes could NOT move down, because each is stated in terms of a HIGHER-ranked zone:
| Shape(s) | Blocked by |
|---|---|
ScrollOptions |
ScrollInputDirection (commands/interaction/runtime/gestures.ts) |
BackCommandOptions, OrientationCommandOptions, AppSwitcherCommandOptions, TvRemoteCommandOptions, AgentDeviceCommandClient |
NavigationCommandOptions / ProjectedNavigationCommandClient (commands/system/navigation-projection.ts) |
MetroPrepareResult, MetroReloadResult |
PrepareMetroRuntimeResult / ReloadMetroResult (metro/client-metro.ts) |
Declaring those in contracts/ would have traded 28 commands -> client inversions for
contracts -> commands and contracts -> metro ones — the foundation depending on the layers above
it, which is worse in kind even though it is fewer edges. Measured, not assumed: the first attempt
put the whole file in contracts/ and the gate went from 42 to 48.
Two keystone moves made the other 84 shapes movable, and both are worth noting as a pattern:
RemoteConnectionProfileFieldsjoined its siblingCloudProviderProfileFieldsincontracts/remote-config-fields.ts. It was the root of the base chain (AgentDeviceClientConfig→AgentDeviceRequestOverrides→DeviceCommandBaseOptions→ every per-command*Options), so one rank-4 declaration was pinning ~80 shapes up with it.DaemonBatchStepmoved tocontracts/batch-step.ts. Itsruntimefield was written asDaemonRequest['runtime'], which dragged the whole daemon request type in to saySessionRuntimeHints— the same type, three zones lower.
Remaining commands -> client (5) needs the upstream declarations to come down first: move
ScrollInputDirection and the navigation-projection types out of commands/, and the Metro
prepare/reload result payloads out of metro/. Each is small; the sequencing is the point. The
mcp -> client edge is different in kind — it is the AgentDeviceClient facade itself, i.e. the
question of whether a command surface should know the client type. That is a design decision, not a
misplaced declaration.
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 ofcore/command-descriptor/registry.ts, socore/is its natural home — butplatforms/apple/plugin.tsandplatforms/vega/plugin.tsimportPUBLIC_COMMANDSto key theirRecord<string, (device) => boolean>default-support maps, andplatformsis rank 1. Moving the catalog tocore/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 bycore/interactors.tsat value level and itself needsdaemon/lease-registry.tsanddaemon/handlers/lease.tsat 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 thrownAppErrors, and a probe through the real router confirmed both reach the wire with the samehint,diagnosticIdandlogPath—finalizeDaemonResponserebuilds every returned failure into a freshAppErrorand re-normalizes it. The cost is that shim (whose own comment records a bug where it droppedretriable/supportedOn) and theResponse | Failureunions 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_DESCRIPTORSis derived from the core descriptors; the "copied VERBATIM from daemon" comment incore/command-descriptor/registry.tsis 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.tsre-implemented the predicate list as an inlined seven-way!==chain while importing the message and hint fromselectors/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. isCommandraised the same refusal withoutIS_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
- Move the 10 outward-facing
daemon/types.tstypes intocontracts/(§2). Mechanical, and it clears most of §1's second cluster. SplitDone — 42 → 18 total inversions. The follow-up is the upstream moves that unblock the last 5 (§1):client/client-types.ts(§1).ScrollInputDirectionand the navigation-projection types out ofcommands/, Metro result payloads out ofmetro/.- Retire platform branches into plugin facets (§5b), highest-count files first.
- Share the remaining duplicated validators (§6), following the
checkIsArgsshape. - Optional: give
daemon/handlers/the directory structure its filenames already imply (§5).