* refactor(daemon): make the ADR 0014 ref frame one owned value
The four `refFrame*` fields on `SessionState` were policed only by the R7
ownership table: any daemon module could write them, and only a full-graph AST
scan could say whose write it was. They are now one `RefFrame` value whose brand
key is private to `src/daemon/ref-frame.ts`, so a module outside that file cannot
construct one and cannot edit the one a session holds; the transitions replace it
whole. Every transition, rejection reason and epoch rule is unchanged.
Readers moved to the accessors ref-frame.ts exports (`refFrameState`,
`refFrameScope`, `refFrameEpoch`, plus a new `refFrameTree` and `refFrame`).
`internal-observation.ts` drops its four-field lineage copy and its field-by-field
comparison: frame identity is now one `===`.
Seen red: with the empty-result early return removed from
`markSessionPartialRefsIssued`, the new frame-identity assertion in
session-snapshot.test.ts fails; restored, it passes. A planted foreign writer
module was rejected by tsc (TS2741 missing brand, TS2540 read-only property)
before deletion.
* docs(depgraph): note the ref frame outgrew its R7 row
* refactor(daemon): make the ref frame nominal, not symbol-branded
A symbol brand on a plain object type stops construction from nothing, but not
`{ ...refFrame(session), state: 'active' }`: object spread copies the symbol key,
so any daemon module could mint an incoherent frame (active state, stale tree)
out of a coherent one and it type-checked. Proven before the fix with a throwaway
module doing exactly that write: tsc reported nothing.
The frame is now a class with `#`-private fields behind getters. That makes the
type nominal, so no object literal is assignable to it — the same probe now fails
with TS2739 (`missing #fields, scope, generation, expired`). Construction stays
inside ref-frame.ts, and the four claim sites (ADR 0014, the SessionState field
doc, and the two in the R7 owner table) now say what the type does and does not
judge: it cannot see a whole frame moved unchanged, which is why the R7 row stays.
Expiry is idempotent by identity again. `expired()` returns THIS frame when the
frame is already expired, rather than an equal copy, which is what the lineage
check in internal-observation.ts compares with `===`. Seen red: with that early
return removed, the tightened ref-frame test fails with "Values have same
structure but are not reference-equal"; green with it.
Also: the ADR 0014 stale-ref help sample seeds its epoch through a real frame
activation again, instead of leaning on the pre-frame snapshotGeneration
fallback, and a find test drops a `?? []` that can no longer be reached.
Behavior is unchanged: same frame contents, same transitions, same admission.
* chore(gates): collapse the four ADR 0014 R7 rows into the owned refFrame value
R7's owner table listed `refFrameState`, `refFrameScope`, `refFrameTree` and
`refFrameGeneration` as four fields that had to be written together by one
module; the code now carries them as one nominal value, so the table carries one
row. R10 follows: 19 writer-owned fields to 16, 22 owner claims to 19.
The row itself stays. The type stops construction, editing and spread-derivation
of a frame outside ref-frame.ts, but it cannot judge a whole frame moved
unchanged — clearing the field, or assigning another session's frame — and the
table can. The comments say that rather than claiming full enforcement.
Seen red: a planted `session.refFrame = undefined` in snapshot-session.ts fails
R7 with "owned by src/daemon/ref-frame.ts"; green once reverted.
* style: apply oxfmt
* refactor(daemon): keep ref-frame expiry module-private
`RefFrame` exposed a public `expired()` method, so any module holding a
frame could derive a new valid one and install it through a reconstructed
session record, past the R7 field scan. Expiry is now a static on the
unexported class, reachable only inside ref-frame.ts; the frame's surface is
four getters. A type-level regression pins that no outside module can
construct, spread, edit, or derive a frame (tsc covers src tests, so a
directive that stops erroring fails typecheck).
* test(daemon): hold the three accessor migrations within the size ratchet
Each file grew by exactly its new ref-frame import; one blank line between
mock blocks goes so the files stay at their merge-base length.
31 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 — the same count the gate
// ratchets against the merge-base with origin/main.
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); the historical audit reported that 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 (daemon/interaction/internal/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 5 is the floor for now)
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 5 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 zone-level type cycle this bullet used to cite is gone: retiring the navigation projection leftclient/client-types.tswith no import fromcommands/at all, and the facade now declares its 14 command methods directly. What keeps the facade abovecommands/is the remaining argument: a narrower port does not exist — 4 files name the facade, but 26 call sites use methods across 13 of its namespaces, so any port would re-declare it. - 1 ×
DaemonCommandRoute(commands/command-explain.ts). The union lives in core so descriptors can name a route without importing the daemon, and the handler table covers it withsatisfies Record<DaemonCommandRoute, …>.command-explain.tsstill type-imports the re-export fromdaemon-command-registry.tsto key an exhaustive owner-file map; that remaining inversion is the commands-zone consumer, not a second source of truth for the union.
All remaining inversions are argued here. R6 (scripts/layering/type-inversion-ratchet.ts) records
no numbers of its own: its reference is the same count taken at the merge-base with origin/main,
so a zone pair can only shrink.
0b. The biggest structural finding is not an inversion
Current status at
e545544dfa85: R9 remains 102, but membership is now commands 33, daemon-server 30, platforms 19, core 12, root composition 5, contracts 2, and client 1. None of the native replay, replay-test, or Maestro engine files is in this component. A graph counterfactual removing the concrete Appleperf-xctrace.tstype edge fromdaemon/types.tslowers it to 91 (and daemon membership from 30 to 21). Removing only the Androidperf.tsedge leaves both counts unchanged at 102/30; removing both produces the same 91/21 as the Apple cut alone. The Apple edge is load-bearing, while removing the Android edge is ownership hygiene. Engine extraction alone leaves R9 at 102.
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 carried 107;
the current boundary moves 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. At the current measured commit it spans commands (33), daemon-server (30),
platforms (19), core (12), root composition (5), contracts (2), and client (1).
Now ratcheted for growth by R9 (scripts/layering/daemon-modularity.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. It was growth-only
here; #1781 A6 made it an equality pin, and the pin is now the merge-base's own measurement
(scripts/layering/ratchet-reference.ts), so a shrink is banked the moment it merges and there is
no slack left to spend. 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 in §0 above. The option this section
used to hold open — moving NAVIGATION_COMMAND_PROJECTIONS out of commands/ — was answered by
deleting it: five direct signatures replaced the registry, so there is no longer a projection
registry whose home is in question.
1. The two remaining type-inversion clusters
§0 above holds both, with the reasoning inline; the gate measures them against the merge-base rather than recording them.
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 at the time; three still cannot, 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)HomeCommandOptions) now live in contracts/client-system.ts, and the facade declares its methods directly |
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 out of commands/, and the Metro prepare/reload result payloads out of
metro/. The navigation-projection leg of this list is done. 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
Status after #1435: the inventory below is historical.
SessionAction, replay-suite results,DaemonLockPolicy, and the public daemon response/artifact/runtime-hint shapes now live below daemon. Four production files outside daemon still importdaemon/types.ts; two are daemon-specific Maestro adapters scheduled to move back under daemon.DaemonRequestitself intentionally remains server-private because it carries admitted leases, callbacks, replay guards, and narrowed flags. Remove the remaining external imports through neutral caller-specific contracts; do not moveDaemonRequestwholesale.
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,
and the four fields have since been replaced by one refFrame value whose type only
ref-frame.ts can construct — so that ownership no longer rests on the R7 table alone.
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,
daemon/interaction/internal/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/).
7. eslint-plugin-boundaries under oxlint: evaluated, works, not adopted
Spiked properly (installed, configured against the real tree, verified by injection) so nobody has
to repeat it. It runs — oxlint's jsPlugins loads npm ESLint plugins directly, so this needs no
ESLint install, no second config and no extra CI step:
{ "jsPlugins": [{ "name": "boundaries", "specifier": "eslint-plugin-boundaries" }] }
R1, R2 and R3 are all expressible, and all three were confirmed to fire on injected violations while every documented exemption held. The two non-obvious settings that make R3 work:
| need | mechanism |
|---|---|
| ignore type-only edges | importKind: "value" on the policy |
ignore dynamic import() |
settings["boundaries/dependency-nodes"] = ["import", "export"] |
Why we did not adopt it. None of these is fatal alone; together they lose more than the
declarative syntax gains, and ZONE_POLICIES gets that syntax anyway:
- No ratchet. The mechanism that took R6 from 61 → 7 across three PRs is a baseline that tolerates N and fails on N+1. A per-file lint rule has no cross-run aggregate, so it cannot do this. Any new boundary with existing violations — the platforms facade has ~89 — would need one disable comment per site.
- It cannot replace the gate. R4 (cycles), R5/R6 (spine ranking + ratchet), R7 (field ownership — not an import rule at all) and R9 (cycle size) are whole-graph or non-import properties. Adopting it means R1-R3 live in one system and R4-R9 in another, so "where is our architecture defined" gets two answers.
- Inline type specifiers are misread.
import { type A, type B } from '…'is fully erased at runtime, but the plugin classifies it as a value import (itsimportKindis statement-level). That produced one real false positive here —providers/limrun/android.ts— flagged as an R3 violation the gate correctly ignores. Only 10 of 1,481 type-only edges use this form, so the blast radius is small, butstatementIsTypeOnlyinmodel.tshandles both spellings and the plugin does not. - Message specificity regressed. With the current non-deprecated selector syntax,
{{dependency.type}}and{{file.type}}interpolated to empty strings, so violations readmust not import commands/with no zone named. ADR 0010 wants every error actionable. - Cost. 230 transitive packages, and
jsPluginsis documented as "in alpha and not subject to semver." A single config attempt produced five deprecation warnings (mode,rules, legacy selectors, legacy templates, rule-levelimportKind) spanning two major migrations.
Worth re-evaluating if the monorepo migration happens — per-package ESLint configs change the
calculus — or once jsPlugins is stable and the ratchet gap is addressable.
Terminal current-state note
The measurements and R3 experiment above are historical audit evidence, not the current layering
contract. After #2082, src/platforms/ is retired: family implementations and family-owned tests
live in their workspace packages, while the shared install-source tests live under
src/__tests__/. Package-level R13 owns platform exports and consumer seams, R65 owns the daemon's
complete concrete-platform ban, and retired-platforms-zone rejects every tracked file under the
old path. Legacy src/platforms spellings remain only in deliberate negative fixtures and
implementation-pattern checks so reintroduction fails closed.
Suggested order from here
Move the 10 outward-facingMostly completed by #1435. Eliminate the four remaining external production importers with caller-specific public contracts or daemon-owned adapters; keepdaemon/types.tstypes intocontracts/(§2).DaemonRequestprivate.SplitDone — 42 → 18 total inversions. The follow-up is the upstream moves that unblock the last 5 (§1):client/client-types.ts(§1).ScrollInputDirectionout ofcommands/, Metro result payloads out ofmetro/. The navigation-projection move is done — the projection was retired rather than relocated.- 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).