Skip to content

Commit 4e06304

Browse files
fix: align layering claims, remove app-log ineffective dynamic import, refresh architecture records (#1222) (#1227)
* fix: align layering claims, remove app-log ineffective dynamic import, refresh architecture records (#1222) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: fix layering spine ordering (cli top), ADR 0009 deferred-scope wording, terse app-log-request-scope comment (#1222) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: show full rank groups in layering spine diagrams; clarify back-edge order vs literal imports (#1222) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: mark replay ADR context historical Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: refresh replay repair and app-log boundary status Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 6275ed0 commit 4e06304

9 files changed

Lines changed: 180 additions & 35 deletions

CONTEXT.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,19 @@ The perfect-shape refactor is complete and merged. Its end-state:
9393
`src/platforms/apple/os/<os>/`. The public wire stays non-breaking: `PUBLIC_PLATFORMS`
9494
(`src/kernel/device.ts`) still emits `ios`/`macos` leaf output. See
9595
[ADR 0009](docs/adr/0009-apple-platform-consolidation.md).
96-
- Folder DAG + layering lint. `kernel`/`remote`/`metro`/`client`/`snapshot`/`screenshot-diff`/
97-
`replay`/`cli-parser`/`daemon-client`+`server`/`sdk` are arranged as an import-direction DAG
98-
(imports point down toward the kernel sink), enforced in CI by `scripts/layering/check.ts`.
99-
The gate rejects production value-import cycles and every ranked target-spine back-edge.
96+
- Folder DAG + layering lint. `scripts/layering/check.ts` enforces two different scopes in CI.
97+
GLOBALLY, across every production source file, it enforces the R1-R3 move rules
98+
(kernel-sink, commands-floor, platforms-seam) and rejects all production static value-import
99+
cycles. Separately, it ranks an explicit target spine — as rank groups, lowest (kernel sink) to
100+
highest, where `A ◄ B` means B may not be outranked by A (the back-edge order the gate rejects), NOT
101+
that every displayed import exists:
102+
`kernel ◄ { contracts, request, selectors, platforms } ◄ core ◄ { commands, cli-schema } ◄ { client, daemon-server } ◄ daemon-client ◄ cli`
103+
and rejects every back-edge within it. Root entrypoints and peripheral zones (`mcp`, `compat`,
104+
`remote`, `metro`, `replay`, `recording`, `snapshot`, `screenshot-diff`, `cloud-webdriver`,
105+
`sdk`, `utils`) are deliberately unranked (`UNRANKED_ZONES` in `scripts/layering/model.ts`):
106+
they still obey R1-R4, but the gate asserts no total back-edge order over them. It is not a
107+
claim that every folder is arranged in one DAG. `model.test.ts` guards that no new zone escapes
108+
this classification silently.
100109
- Agent-cost. Responses carry a cost block and MCP `outputSchema`, rendered through a leveled
101110
`ResponseView`.
102111

docs/adr/0009-apple-platform-consolidation.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,18 @@ and snapshot fidelity is uneven (the deep-RN AX-server fallback is iOS-simulator
5252

5353
This composes with ADR 0008 (the descriptor's capability facet) and ADR 0003.
5454

55-
Implementation status as of 2026-06:
55+
Implementation status as of 2026-07:
5656

57-
- Shipped: additive `appleOs` groundwork, the shared Apple engine under `src/platforms/apple/core`, macOS leaf
58-
files under `src/platforms/apple/os/macos`, direct internal imports to the Apple modules, and visionOS
59-
profile/build/discovery groundwork.
60-
- Deferred: the public `Platform` collapse from `ios`/`macos` to `apple`, a dedicated tvOS leaf, per-`AppleOS`
61-
capability tables, and any watchOS unsupported sentinel. watchOS remains out of scope for the current
62-
consolidation.
57+
- Shipped: additive `appleOs` groundwork; the shared Apple engine under `src/platforms/apple/core`; macOS
58+
leaf files under `src/platforms/apple/os/macos`; a dedicated tvOS leaf under `src/platforms/apple/os/tvos`;
59+
direct internal imports to the Apple modules; the per-`AppleOS` capability table
60+
(`APPLE_OS_CAPABILITIES`, `src/platforms/apple/capabilities.ts`, parity-pinned against the pre-table
61+
predicates); the watchOS **unsupported sentinel** (reserved in the `AppleOS` type and interactor-rejected —
62+
XCUITest cannot drive watchOS UI — never produced by discovery); and visionOS profile/build/discovery
63+
groundwork.
64+
- Deferred: the public `Platform` collapse from `ios`/`macos` to `apple` (the last, highest-diff step; the
65+
public wire still emits `ios`/`macos` leaves via `PUBLIC_PLATFORMS`) and net-new visionOS spatial-input QA.
6366

64-
This ADR owns the architectural decision; implementation progress for the remaining platform-plugin work is
65-
tracked in GitHub issues under the Phase 3 umbrella (#972).
67+
This ADR owns the architectural decision. The Phase 3 platform-plugin umbrella (#972) is closed; within
68+
this consolidation the public `Platform` collapse above is the last deferred step (net-new visionOS
69+
spatial-input QA is separate follow-up, outside the consolidation).

docs/adr/0012-interactive-replay.md

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22

33
## Status
44

5-
Proposed (2026-07-10). Nothing in this ADR is implemented yet.
5+
Accepted (2026-07-10). Migration steps 1-6 and 8 have shipped; step 7 (benchmark extension) remains
6+
deferred. See [Migration progress](#migration-progress) for the per-step landing record.
67

7-
## Context
8+
## Context (historical baseline)
89

9-
Replay today is deterministic. `.ad` scripts are plain text — one action per line, `#` comments, a
10-
`context platform=... device=... theme=...` header (`src/replay/script.ts`) — recorded via
11-
`open --save-script` (`src/daemon/session-action-recorder.ts`, `src/daemon/session-script-writer.ts`)
12-
or hand-written, and executed step-by-step by `runReplayScriptFile`
10+
This section records the repository state audited on 2026-07-10, before the migration steps recorded
11+
below shipped. Its present-tense observations are historical evidence;
12+
[Migration progress](#migration-progress) is the authoritative record of current behavior.
13+
14+
At acceptance, replay was deterministic. `.ad` scripts were plain text — one action per line, `#`
15+
comments, and a `context platform=... device=... theme=...` header (`src/replay/script.ts`) — recorded
16+
via `open --save-script` (`src/daemon/session-action-recorder.ts`,
17+
`src/daemon/session-script-writer.ts`) or hand-written, and executed step-by-step by
18+
`runReplayScriptFile`
1319
(`src/daemon/handlers/session-replay-runtime.ts`) under the daemon's `replay`/`test` commands
1420
(`src/daemon/handlers/session-replay.ts`). Recorded touch/fill/get targets are selector chains with
1521
`||` alternates (`buildSelectorChainForNode(...).join(' || ')`,
@@ -18,7 +24,7 @@ or hand-written, and executed step-by-step by `runReplayScriptFile`
1824
(`src/compat/maestro/`); progress is step-indexed (`stepIndex`/`stepTotal` in
1925
`emitReplayTestActionProgress`, `session-replay-runtime.ts:243-260`).
2026

21-
Recovery is opt-in `--update`/`-u` healing (`replayUpdate` flag,
27+
Recovery was opt-in `--update`/`-u` healing (`replayUpdate` flag,
2228
`src/commands/cli-grammar/flag-definitions-workflow.ts`). It only fires after a step has already
2329
returned a hard failure (`session-replay-runtime.ts:118-149`: `if (!shouldUpdate) return failure; ...
2430
healReplayAction(...)`), and it only retries the SAME recorded selector material —
@@ -67,14 +73,14 @@ zero on the happy path and paying only where reality diverged from the recording
6773
... then smallest on-screen area") but never disclosed per response — an agent that hasn't read the
6874
help topic, or whose target moved between recording and replay, gets no signal a heuristic rather than
6975
an exact match chose its target.
70-
- **(c) No target-binding verification exists anywhere in this path.** `--verify`
76+
- **(c) No target-binding verification existed in this path at acceptance.** `--verify`
7177
(`captureEvidenceBaseline`, `resolution.ts:45-58,104-134`; the `verifyEvidence` guarantee cell in ADR
7278
0011's registry) attaches a pre/post-action node diff so the caller can see SOMETHING changed — it
7379
says nothing about whether the CORRECT node was the one tapped. A wrong-but-plausible pick (the
7480
sibling "Prevent Remove" button) produces a real, visible diff and is still the wrong action.
75-
- **(d) Heal auditability is a bare count.** A successful `--update` run returns
81+
- **(d) Heal auditability was a bare count.** A successful `--update` run returned
7682
`{ replayed, healed, ... }` (`session-replay-runtime.ts:186-195`) — `healed` is a number, nothing
77-
else — and rewrites the `.ad` file in place via `writeReplayScript`
83+
else — and rewrote the `.ad` file in place via `writeReplayScript`
7884
(`session-replay-runtime.ts:182-184`, `src/replay/script.ts:459-484`) with no diff shown anywhere in
7985
the response.
8086
- **(e) This silent-pick default is in real tension with this repo's general posture toward ambiguity.**
@@ -774,3 +780,25 @@ each states its dependencies explicitly.
774780
boundary watermark on `replay` (R1/R6), the writer's post-watermark slice and bare-`@ref` fail-loud
775781
guard (R4/R6) — the healed-script emission otherwise reuses `close --save-script`'s existing
776782
`session.actions` serializer.
783+
784+
## Migration progress
785+
786+
Landing record for the plan above (main as of 2026-07-13). This section tracks progress only; it does
787+
not restate or amend the decisions.
788+
789+
| Step | Decision | Status | Landed in |
790+
| --- | --- | --- | --- |
791+
| 1. Resolution disclosure | 2 | Shipped | #1193 |
792+
| 2. Structured divergence transport | 4 (report) | Shipped | #1197 |
793+
| 3. `.ad` target annotations, inert | 3 (parser/writer) | Shipped | #1196 |
794+
| 4. Target-binding verification | 3 (enforcement) | Shipped | #1209 |
795+
| 5. `replay --from` + `--plan-digest` resume | 4 (resume) | Shipped | #1211 |
796+
| 6. `--update` retirement | 1 | Shipped | #1211 |
797+
| 7. Benchmark extension | 5 | Deferred ||
798+
| 8. Agent-supervised re-record repair | 6 | Shipped | #1228 |
799+
800+
Step 4 (#1209) added the `selector-miss`/`identity-mismatch`/`identity-unverifiable` divergence kinds
801+
and a post-resolution target guard that cross-checks the dispatched winner against the verified member.
802+
Issue #1221 ("Complete ADR 0012 replay target-binding verification") was closed as already implemented:
803+
its verification scope is covered by step 4. Step 7 remains deferred. Step 8 implements the repair
804+
design accepted in #1226 and shipped in #1228.

docs/agents/contract-projection-output-economy-spike.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Selected commands:
2020

2121
These commands already have closed neutral results in `src/contracts/navigation.ts`, typed
2222
`CommandResultMap` entries, executable definitions, Node client methods, and MCP output schemas.
23-
They do not overlap the broad-return client methods being changed under #1183.
23+
They do not overlap the broad-return client methods narrowed under #1183 (since closed).
2424

2525
The baseline has three independent command-to-surface declarations per selected command:
2626

scripts/layering/check.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
// Import-direction lint — enforces the folder DAG established by the Phase-5
22
// folder moves (see CONTEXT.md, "Architecture: folder DAG + layering lint").
33
//
4-
// Target DAG (imports point DOWN, toward the kernel sink):
5-
// kernel ◄ platforms ◄ core ◄ commands ◄ { cli, client, daemon/server }
6-
// client ◄ daemon/client remote, metro ◄ daemon/client
7-
// sdk = re-export barrels only
4+
// Ranked target spine, as rank groups lowest (kernel sink) to highest. `A ◄ B` means B may not
5+
// be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:
6+
// kernel ◄ { contracts, request, selectors, platforms } ◄ core ◄ { commands, cli-schema }
7+
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
8+
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts)
89
//
9-
// The target DAG is complete. This gate enforces the three move rules, rejects
10-
// all production value-import cycles, and rejects every ranked target-spine
11-
// back-edge.
10+
// This gate enforces three things, with two different scopes:
11+
// - GLOBALLY, across every production source file: the R1-R3 move rules and
12+
// rejection of all production static value-import cycles (R4).
13+
// - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e.
14+
// an import whose source zone outranks its target zone.
15+
// Root and peripheral zones (see `UNRANKED_ZONES` in model.ts) are deliberately
16+
// NOT ranked: they still participate in R1-R4, but the gate makes no back-edge
17+
// claim about them. It is not a claim that every folder is arranged in one total
18+
// order — only the ranked spine is.
1219

1320
import { execFileSync } from 'node:child_process';
1421
import fs from 'node:fs';
@@ -160,8 +167,9 @@ function checkBackEdges(edges: readonly ResolvedImportEdge[]): Violation[] {
160167
function report(files: readonly string[], violations: readonly Violation[]): number {
161168
if (violations.length === 0) {
162169
process.stdout.write(
163-
`Layering guard: OK — ${files.length} source files satisfy R1-R3, contain no ` +
164-
`value-import cycles, and contain no target-spine back-edges.\n`,
170+
`Layering guard: OK — ${files.length} source files satisfy R1-R3 and contain no ` +
171+
`value-import cycles (both checked globally); the ranked target spine contains no ` +
172+
`back-edges (root/peripheral zones are intentionally unranked).\n`,
165173
);
166174
return 0;
167175
}

scripts/layering/model.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import assert from 'node:assert/strict';
22
import { test } from 'node:test';
33
import { listSourceFiles } from './check.ts';
44
import {
5+
RANKED_ZONES,
6+
UNRANKED_ZONES,
7+
classifyZone,
58
collectBackEdges,
9+
collectZones,
610
findValueImportCycles,
711
parseImports,
812
resolveImportEdges,
13+
unclassifiedZones,
914
} from './model.ts';
1015

1116
test('parseImports distinguishes value, type-only, dynamic, and value re-export edges', () => {
@@ -94,6 +99,40 @@ test('neutral ownership zones reject value imports into higher layers', () => {
9499
});
95100
});
96101

102+
test('ranked and unranked zones are disjoint and both non-empty', () => {
103+
assert.ok(RANKED_ZONES.size > 0);
104+
assert.ok(UNRANKED_ZONES.size > 0);
105+
const overlap = [...RANKED_ZONES].filter((zone) => UNRANKED_ZONES.has(zone));
106+
assert.deepEqual(overlap, [], 'a zone cannot be both ranked and intentionally unranked');
107+
});
108+
109+
test('classifyZone separates the ranked spine from intentionally-unranked zones', () => {
110+
assert.equal(classifyZone('kernel'), 'ranked');
111+
assert.equal(classifyZone('daemon-server'), 'ranked');
112+
assert.equal(classifyZone('(root)'), 'unranked');
113+
assert.equal(classifyZone('utils'), 'unranked');
114+
assert.equal(classifyZone('mcp'), 'unranked');
115+
// A zone that is neither ranked nor listed peripheral must be flagged, never
116+
// silently treated as back-edge-free.
117+
assert.equal(classifyZone('not-a-real-zone'), 'unclassified');
118+
});
119+
120+
test('every production zone is deliberately classified as ranked or unranked', () => {
121+
// Drift guard: a new src/<folder>/ (or a daemon-client/server split) forces a
122+
// deliberate ranked-vs-peripheral decision here instead of silently escaping
123+
// spine back-edge detection. If this fails, add the new zone to TARGET_DAG_RANK
124+
// (ranked spine) or UNRANKED_ZONES (root/peripheral) in model.ts.
125+
assert.deepEqual(unclassifiedZones(listSourceFiles()), []);
126+
127+
// The classification must also stay honest to the tree: every zone the model
128+
// names is a real production zone, so the docs cannot list a spine or peripheral
129+
// zone that no longer exists.
130+
const presentZones = collectZones(listSourceFiles());
131+
const namedZones = new Set([...RANKED_ZONES, ...UNRANKED_ZONES]);
132+
const staleNamedZones = [...namedZones].filter((zone) => !presentZones.has(zone)).sort();
133+
assert.deepEqual(staleNamedZones, []);
134+
});
135+
97136
test('listSourceFiles includes root-level src/*.ts production files', () => {
98137
const files = new Set(listSourceFiles());
99138
for (const rootFile of ['src/cli.ts', 'src/command-catalog.ts', 'src/backend.ts']) {

scripts/layering/model.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export type ResolvedImportEdge = ImportEdge & {
1616

1717
export type BackEdgeMap = Record<string, string[]>;
1818

19+
// The ranked target spine. Back-edge detection is defined ONLY between two ranked
20+
// zones: an edge whose source outranks its target (lower number imports higher) is a
21+
// spine back-edge. Zones NOT in this map are intentionally unranked (see
22+
// `UNRANKED_ZONES`); the gate does not rank them, so ranking their edges would claim a
23+
// back-edge guarantee the code does not make. Every production zone must be either
24+
// ranked here or listed as unranked — `unclassifiedZones` and `model.test.ts` guard
25+
// that no zone is silently unclassified.
1926
const TARGET_DAG_RANK = new Map([
2027
['kernel', 0],
2128
['contracts', 1],
@@ -31,6 +38,40 @@ const TARGET_DAG_RANK = new Map([
3138
['cli', 6],
3239
]);
3340

41+
export const RANKED_ZONES: ReadonlySet<string> = new Set(TARGET_DAG_RANK.keys());
42+
43+
// Zones deliberately left OUT of the ranked spine. They are NOT unenforced: every file
44+
// in them is still subject to the global production value-import cycle rejection (R4)
45+
// and the R1-R3 move rules. They only opt out of spine back-edge ranking, for one of
46+
// two deliberate reasons:
47+
// - root: `(root)` entrypoints (src/cli.ts, src/backend.ts, …) compose the spine from
48+
// above rather than sitting inside it.
49+
// - peripheral: satellite feature/adapter zones the spine does not depend on in a
50+
// fixed rank order. Assigning them a rank would invent a back-edge direction the
51+
// architecture does not commit to.
52+
export const UNRANKED_ZONES: ReadonlySet<string> = new Set([
53+
'(root)',
54+
'cloud-webdriver',
55+
'compat',
56+
'mcp',
57+
'metro',
58+
'recording',
59+
'remote',
60+
'replay',
61+
'screenshot-diff',
62+
'sdk',
63+
'snapshot',
64+
'utils',
65+
]);
66+
67+
export type ZoneClassification = 'ranked' | 'unranked' | 'unclassified';
68+
69+
export function classifyZone(zone: string): ZoneClassification {
70+
if (RANKED_ZONES.has(zone)) return 'ranked';
71+
if (UNRANKED_ZONES.has(zone)) return 'unranked';
72+
return 'unclassified';
73+
}
74+
3475
function scanDynamicImports(line: string, lineNo: number): ImportEdge[] {
3576
const edges: ImportEdge[] = [];
3677
const re = /import\s*\(\s*['"]([^'"]+)['"]/g;
@@ -102,12 +143,25 @@ export function topFolder(file: string): string {
102143
return match ? match[1]! : '(root)';
103144
}
104145

105-
function targetDagZone(file: string): string {
146+
export function targetDagZone(file: string): string {
106147
if (file.startsWith('src/daemon/client/')) return 'daemon-client';
107148
if (file.startsWith('src/daemon/')) return 'daemon-server';
108149
return topFolder(file);
109150
}
110151

152+
// The set of zones every production file resolves into. A zone that is neither ranked
153+
// nor listed as intentionally unranked is an unclassified drift signal.
154+
export function collectZones(files: readonly string[]): Set<string> {
155+
return new Set(files.map(targetDagZone));
156+
}
157+
158+
// Zones present in `files` that are neither ranked nor intentionally unranked. A new
159+
// `src/<folder>/` must be classified deliberately; leaving it unclassified would let
160+
// its back-edges silently escape the ranked spine. Empty means the partition holds.
161+
export function unclassifiedZones(files: readonly string[]): string[] {
162+
return [...collectZones(files)].filter((zone) => classifyZone(zone) === 'unclassified').sort();
163+
}
164+
111165
function resolveTargetFile(
112166
fromFile: string,
113167
spec: string,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Isolates request-scope provider composition behind its dynamic import.
2+
// The shared app-log implementation remains eager for observability and teardown.
3+
export { withAppLogProvider } from './app-log.ts';

src/daemon/request-platform-providers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [
271271
},
272272
async appendWrapper(scopedProviders, wrappers) {
273273
if (!scopedProviders.appLog?.provider) return;
274-
const { withAppLogProvider } = await import('./app-log.ts');
274+
const { withAppLogProvider } = await import('./app-log-request-scope.ts');
275275
appendRequestProviderWrapper(wrappers, scopedProviders.appLog, withAppLogProvider);
276276
},
277277
},

0 commit comments

Comments
 (0)