Skip to content

Commit f1b0fab

Browse files
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>
1 parent e61edf7 commit f1b0fab

9 files changed

Lines changed: 168 additions & 25 deletions

‎CONTEXT.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,17 @@ 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 —
100+
`kernel ◄ platforms ◄ core ◄ commands ◄ { cli, client, daemon-server } ◄ daemon-client` — and
101+
rejects every back-edge within it. Root entrypoints and peripheral zones (`mcp`, `compat`,
102+
`remote`, `metro`, `replay`, `recording`, `snapshot`, `screenshot-diff`, `cloud-webdriver`,
103+
`sdk`, `utils`) are deliberately unranked (`UNRANKED_ZONES` in `scripts/layering/model.ts`):
104+
they still obey R1-R4, but the gate asserts no total back-edge order over them. It is not a
105+
claim that every folder is arranged in one DAG. `model.test.ts` guards that no new zone escapes
106+
this classification silently.
100107
- Agent-cost. Responses carry a cost block and MCP `outputSchema`, rendered through a leveled
101108
`ResponseView`.
102109

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,17 @@ 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; only the
68+
public `Platform` collapse above remains deferred.

‎docs/adr/0012-interactive-replay.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
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 have shipped; steps 7 (benchmark extension) and 8
6+
(agent-supervised re-record repair) remain. See [Migration progress](#migration-progress) for the
7+
per-step landing record.
68

79
## Context
810

@@ -774,3 +776,25 @@ each states its dependencies explicitly.
774776
boundary watermark on `replay` (R1/R6), the writer's post-watermark slice and bare-`@ref` fail-loud
775777
guard (R4/R6) — the healed-script emission otherwise reuses `close --save-script`'s existing
776778
`session.actions` serializer.
779+
780+
## Migration progress
781+
782+
Landing record for the plan above (main as of 2026-07-12). This section tracks progress only; it does
783+
not restate or amend the decisions.
784+
785+
| Step | Decision | Status | Landed in |
786+
| --- | --- | --- | --- |
787+
| 1. Resolution disclosure | 2 | Shipped | #1193 |
788+
| 2. Structured divergence transport | 4 (report) | Shipped | #1197 |
789+
| 3. `.ad` target annotations, inert | 3 (parser/writer) | Shipped | #1196 |
790+
| 4. Target-binding verification | 3 (enforcement) | Shipped | #1209 |
791+
| 5. `replay --from` + `--plan-digest` resume | 4 (resume) | Shipped | #1211 |
792+
| 6. `--update` retirement | 1 | Shipped | #1211 |
793+
| 7. Benchmark extension | 5 | Deferred | — |
794+
| 8. Agent-supervised re-record repair | 6 | Accepted; implementation pending | #1226 (decision only) |
795+
796+
Step 4 (#1209) added the `selector-miss`/`identity-mismatch`/`identity-unverifiable` divergence kinds
797+
and a post-resolution target guard that cross-checks the dispatched winner against the verified member.
798+
Issue #1221 ("Complete ADR 0012 replay target-binding verification") was closed as already implemented:
799+
its verification scope is covered by step 4. Step 7 remains deferred; #1226 accepted the step 8 design
800+
without implementing its runtime behavior.

‎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: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
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):
4+
// Ranked target spine (imports point DOWN, toward the kernel sink):
55
// kernel ◄ platforms ◄ core ◄ commands ◄ { cli, client, daemon/server }
6-
// client ◄ daemon/client remote, metro ◄ daemon/client
7-
// sdk = re-export barrels only
6+
// client ◄ daemon/client
87
//
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.
8+
// This gate enforces three things, with two different scopes:
9+
// - GLOBALLY, across every production source file: the R1-R3 move rules and
10+
// rejection of all production static value-import cycles (R4).
11+
// - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e.
12+
// an import whose source zone outranks its target zone.
13+
// Root and peripheral zones (see `UNRANKED_ZONES` in model.ts) are deliberately
14+
// NOT ranked: they still participate in R1-R4, but the gate makes no back-edge
15+
// claim about them. It is not a claim that every folder is arranged in one total
16+
// order — only the ranked spine is.
1217

1318
import { execFileSync } from 'node:child_process';
1419
import fs from 'node:fs';
@@ -160,8 +165,9 @@ function checkBackEdges(edges: readonly ResolvedImportEdge[]): Violation[] {
160165
function report(files: readonly string[], violations: readonly Violation[]): number {
161166
if (violations.length === 0) {
162167
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`,
168+
`Layering guard: OK — ${files.length} source files satisfy R1-R3 and contain no ` +
169+
`value-import cycles (both checked globally); the ranked target spine contains no ` +
170+
`back-edges (root/peripheral zones are intentionally unranked).\n`,
165171
);
166172
return 0;
167173
}

‎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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Lazy entry for request-scoped app-log provider composition.
2+
//
3+
// `request-platform-providers` composes every provider wrapper behind a dynamic import so
4+
// provider machinery stays out of the eager daemon graph. `app-log.ts` cannot be that lazy
5+
// boundary itself: `session-teardown`/`session-observability` import it statically for the
6+
// eager start/stop/doctor paths, so importing it dynamically was ineffective (the module is
7+
// already in the daemon chunk). This re-export is the dedicated lazy boundary for the
8+
// request-scope wrapper, keeping the dynamic import effective without changing where the
9+
// eager app-log code lives.
10+
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)