Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 38 additions & 22 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@ R65. The per-command cutover table was retired after completion as required by s
enforces the permanent facts-only admission and runtime-proof invariants without naming historical
routes or handler functions.

The #2082 extraction completes the physical ownership boundary: all six platform-family
implementations and their family-owned tests live behind private package exports, `src/platforms`
is retired, and the former R3 folder seam is gone. R13 now owns concrete platform-package import
direction and implementation laziness; the `retired-platforms-zone` gate rejects any attempt to
recreate the old root path.

## Rules at a glance

- Daemon device-execution code depends on platform-neutral contracts. Concrete device mechanics
live in private `@agent-device/platform-*` packages and are value-imported only by the root
composition module.
- Daemon device-execution code depends on platform-neutral contracts. Concrete device mechanics for
the six canonical families live in private `@agent-device/platform-*` packages; each family owns
its implementation and family-specific tests, while shared install-source tests are root-owned
under `src/__tests__/`. The root composition module and R13-governed named consumer facades are
the only production static value-import sites.
- The platform registry is **metadata-eager and implementation-lazy**. Cheap family identity,
inventory entrypoints, and static fact declarations may load at composition time; platform
mechanics and process-lived helper managers load only when discovery or the first binding for that
Expand Down Expand Up @@ -105,9 +113,11 @@ provider resolver table and wrapper ordering; only the canonical root may load i
lazy until a request enters a provider scope. Daemon device-execution modules import the canonical
root interface or runtime contracts only.
Shared runtime interfaces and neutral data types live in `@agent-device/contracts`. In production,
only that composition module or its one R13-governed private implementation submodule may import a
concrete platform package; reusable types do not leak through type-only platform imports. Platform
packages may import contracts, kernel/domain packages,
only that composition module, its one R13-governed private implementation submodule, or the
R13-governed consumer seams under `src/core/interactors/` may statically import concrete platform
package roots; approved runtime hosts use deferred or type-only root imports, and named Apple
facades expose only their governed domain seam. Reusable types do not leak through type-only
platform imports. Platform packages may import contracts, kernel/domain packages,
and explicitly injected host capabilities; they may not import daemon requests or responses, mutable
session state, command catalogs/grammar, root implementation files, sibling platform packages, or raw
process primitives outside the shared host-command port. R13 applies these rules to static, type-only,
Expand Down Expand Up @@ -167,22 +177,27 @@ selection, R11/R13 package enumeration, and the composite typecheck project list
>
> Enforcement: each substrate package's exported subpaths are pinned in
> `package-boundaries.test.ts` (widening fails the gate), the contracts mechanics gate stays
> planted red, and the `platforms-root-shape` rule rejects any new shared file or directory
> appearing directly under `src/platforms`.
> planted red, and the `retired-platforms-zone` rule rejects every production, test, or fixture
> file under the former `src/platforms` path.

The Apple XCUITest runner client is a durable platform-owned implementation facet colocated
inside `packages/platform-apple` as the `src/runner/` subtree (#2040) — Apple mechanics belong to
the Apple package. R13 models the facet by enumeration rather than by exception sprawl: the family
exports its root façade plus exactly the `./runner`, `./runner/client`, and `./runner/test-host`
subpaths; the `./runner` façade subpath is the seam through which daemon and root consumers reach
runner mechanics directly today; the host-bound `./runner/client` factory has one composition root
and `./runner/test-host` one vitest installer; the facet owns its cache files and usbmux sockets
(the ambient-host rule exempts exactly that subtree), while raw process primitives stay banned —
host authority still enters through one focused injected port (`AppleRunnerHost`: process
execution, diagnostics, retry, probes, locks, foreground Apple tooling, physical-device control)
constructed by exactly one composition root. No current issue owns migrating the runner's direct
consumers behind the composition gateway; if such a migration retires them, the `./runner` seam
narrows with it, but the facet itself is the intended ownership model, not a temporary exception.
the Apple package. R13 models the package by enumeration rather than by exception sprawl: the family
exports its root façade plus fourteen named domain/mechanics facades — `./app-lifecycle`,
`./app-resolution`, `./debug-symbols`, `./doctor`, `./interactions`, `./install-artifact`, `./macos`,
`./perf`, `./physical-device`, `./runner-owner`, `./runner/operations`, `./simctl`, `./simulator`, and
`./tool-provider` — as well as exactly the `./runner`, `./runner/client`, and `./runner/test-host`
subpaths. The named facades replace root-only access for synchronous domain consumers without a
broad compatibility barrel; R13 pins the exact export set and allowed consumer seams. The
`./runner` façade subpath is the seam through which daemon and root consumers reach runner mechanics
directly today; the host-bound `./runner/client` factory has one composition root and
`./runner/test-host` one vitest installer; the facet owns its cache files and usbmux sockets (the
ambient-host rule exempts exactly that subtree), while raw process primitives stay banned — host
authority still enters through one focused injected port (`AppleRunnerHost`: process execution,
diagnostics, retry, probes, locks, foreground Apple tooling, physical-device control) constructed by
exactly one composition root. No current issue owns migrating the runner's direct consumers behind
the composition gateway; if such a migration retires them, the `./runner` seam narrows with it, but
the facet itself is the intended ownership model, not a temporary exception.
Mechanics-facet declarations are explicit per family: the Apple runner and Android mechanics/host
facets are enumerated above, and a new family adds its own named facet only with an owning consumer
and evidence.
Expand Down Expand Up @@ -770,11 +785,12 @@ belongs to its domain: test-IME restoration is durable device state with marker-
helper stops follow the owning platform module's lifecycle policy, and close-time cleanup consumes
neutral owner services.

R65 is the end-state enforcement: its planted-red AST tests reject every dependency edge — static,
R65 is the daemon-side end-state enforcement: its planted-red AST tests reject every dependency edge — static,
dynamic, re-export, and type-only — from production `src/daemon/**` modules (test files excluded,
matching the layering scanner's scope) to `src/platforms/**` and concrete
`@agent-device/platform-*` packages. The daemon has been removed from the R3 seam, so platform
freedom is structurally enforced rather than periodically measured.
`@agent-device/platform-*` packages. R13 governs concrete package imports across the whole tree,
while `retired-platforms-zone` prevents the old root seam from being recreated; platform freedom is
therefore structurally enforced rather than periodically measured.

## Relationship to prior decisions

Expand Down
10 changes: 10 additions & 0 deletions docs/dependency-graph-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,16 @@ declarative syntax gains, and `ZONE_POLICIES` gets that syntax anyway:
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

1. ~~**Move the 10 outward-facing `daemon/types.ts` types into `contracts/`** (§2).~~ Mostly
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-affected/device-lanes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ test('shared runtime surface owns every device lane', () => {
'packages/kernel/src/errors.ts',
'src/daemon/android-system-dialog.ts', // naming convention in a shared dir, not a boundary
'test/integration/smoke-daemon-clean.test.ts',
'src/platforms/install-source.ts',
'packages/provision-kit/src/install-source.ts',
]) {
assert.equal(deviceLaneLeaf(file), 'shared', file);
assert.deepEqual(lanes(file), [
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-affected/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test('production source selects static/build gates and delegates tests to Vitest
}
});

test('platform source additionally selects provider-integration', () => {
test('platform package source additionally selects provider-integration', () => {
const result = ids(['packages/platform-apple/src/core/app-resolution.ts']);
assert.ok(result.includes('provider-integration'));
assert.ok(result.includes('coverage'));
Expand Down
19 changes: 1 addition & 18 deletions scripts/check-affected/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,27 +259,10 @@ const staticTsGates: OwnershipRule = ({ file, isTs, underSrc, underTest }) =>

const srcProdGate: OwnershipRule = ({ file, isSrcProd }) => {
if (!isSrcProd) return [];
const selections = [
return [
reason('layering', file, 'gate:layering', 'layering guard reads production src/ modules'),
reason('build', file, 'src-prod', 'production source is compiled by the build'),
];
if (file.startsWith('src/platforms/')) {
selections.push(
reason(
'provider-integration',
file,
'platform-src',
'platform source shapes device/provider wire behavior',
),
reason(
'coverage',
file,
'platform-src',
'Testing Matrix requires coverage for platform/device-response changes',
),
);
}
return selections;
};

function isNodeIntegrationPath(file: string): boolean {
Expand Down
23 changes: 13 additions & 10 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
//
// Ranked target spine, as rank groups lowest to highest. `A ◄ B` means B may not
// be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:
// { contracts, request, selectors, platforms } ◄ core ◄ { commands, cli-schema }
// { contracts, request, selectors } ◄ core ◄ { commands, cli-schema }
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts. The former rank-0 kernel
// zone lives in packages/kernel since #1490 W0; R11 owns its boundary.)
//
// This gate enforces five things, across four scopes:
// - GLOBALLY, across every production source file: the R2-R3 move rules and
// - GLOBALLY, across every production source file: the remaining R2 move rule and
// rejection of all production static value-import cycles (R4). R1 kernel-sink
// retired with the kernel's move to packages/kernel (#1490 W0); R8
// zero-dep-job-closure retired with the last `install-deps: false` job
Expand All @@ -20,8 +20,8 @@
// same inversion measured over TYPE-ONLY edges (R6).
// - Over the DAEMON only: SessionState field ownership (R7), because the session
// record is store-owned mutable state that any daemon module can write; and the terminal
// concrete-platform boundary (R65), which rejects every import form into src/platforms or a
// platform package.
// concrete-platform boundary (R65), which rejects every import form into the retired
// src/platforms path or a platform package.
// - Over the TYPE GRAPH: the largest type-level import cycle is pinned by
// equality (R9). R4 keeps the value graph acyclic, so these cycles are free at
// runtime but bound what can be read in isolation; growth fails, and so does a
Expand Down Expand Up @@ -82,7 +82,7 @@ import {
} from './package-boundaries.ts';
import {
checkPlatformPackagePolicy,
checkPlatformsRootShape,
checkRetiredPlatformsZone,
platformPackagePolicySummary,
} from './platform-package-policy.ts';
import {
Expand All @@ -96,7 +96,11 @@ import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownersh
import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts';
import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts';
import { checkDaemonPlatformBoundary } from './daemon-platform-boundary.ts';
import { listTrackedProductionSources, listTrackedTypeScriptFiles } from './tracked-sources.ts';
import {
listTrackedPlatformZoneFiles,
listTrackedProductionSources,
listTrackedTypeScriptFiles,
} from './tracked-sources.ts';
import { runtimeExecutionIntegrityViolations } from './runtime-execution-policy.ts';
import { sourceExecutionCompatibilityViolations } from './source-execution-policy.ts';
import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts';
Expand Down Expand Up @@ -469,7 +473,7 @@ function report(
): number {
if (violations.length === 0) {
process.stdout.write(
`Layering guard: OK — ${files.length} source files satisfy R2-R3 and contain no ` +
`Layering guard: OK — ${files.length} source files satisfy R2 and contain no ` +
`value-import cycles (both checked globally); the ranked target spine contains no ` +
`back-edges (only the composition root is unranked among src zones), and its type-only ` +
`inversions match the R6 ratchet (${Object.values(TYPE_INVERSION_BASELINE).reduce((sum, count) => sum + count, 0)} remaining); ` +
Expand Down Expand Up @@ -546,7 +550,7 @@ export const LAYERING_RULE_IDS = [
'bin-alias-fast-path',
'package-boundaries',
'platform-package-policy',
'platforms-root-shape',
'retired-platforms-zone',
] as const;

export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
Expand Down Expand Up @@ -584,8 +588,7 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
readTrackedPlatformPackageDeclarations(repoRoot),
{ untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) },
),
'platforms-root-shape': (context) =>
checkPlatformsRootShape([...context.allTypeScriptSources.keys()]),
'retired-platforms-zone': () => checkRetiredPlatformsZone(listTrackedPlatformZoneFiles(repoRoot)),
};

export function main(): number {
Expand Down
3 changes: 2 additions & 1 deletion scripts/layering/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ test('classifyZone separates the ranked spine from intentionally-unranked zones'
assert.equal(classifyZone('daemon-server'), 'ranked');
assert.equal(classifyZone('(root)'), 'unranked');
assert.equal(classifyZone('platform-runtime'), 'unranked');
assert.equal(classifyZone('platforms'), 'unclassified');
assert.equal(classifyZone('utils'), 'ranked');
// Every satellite zone joined the spine; only the composition root stays out, because R2
// forbids daemon/ from importing commands/ so the files that wire them cannot be ranked.
Expand Down Expand Up @@ -416,7 +417,7 @@ test('largestTypeCycleSize counts type-only cycles and ignores dynamic ones', ()
]);

// A loop closed through a DYNAMIC import is excluded on purpose: a lazy seam is not a
// comprehension barrier, and R3 relies on dynamic imports existing. With no non-dynamic edge at
// comprehension barrier. With no non-dynamic edge at
// all no file enters the walk, so the floor here is 0 rather than 1 — specified, not incidental.
const dynamicCycle = resolveImportEdges(
new Map(
Expand Down
38 changes: 23 additions & 15 deletions scripts/layering/platform-package-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { test } from 'node:test';
import {
CANONICAL_PLATFORM_FAMILIES,
checkPlatformPackagePolicy,
checkPlatformsRootShape,
checkRetiredPlatformsZone,
type PlatformPackageDeclaration,
} from './platform-package-policy.ts';
import { classifyZone } from './model.ts';
Expand Down Expand Up @@ -102,7 +102,7 @@ test('the inventory substrate has six private lazy packages and one exact compos
});

test('retired platform family implementations are rejected from src/platforms', () => {
const violations = checkPlatformsRootShape([
const violations = checkRetiredPlatformsZone([
'src/platforms/apple/core/apps.ts',
'src/platforms/harmonyos/app-lifecycle.ts',
'src/platforms/linux/snapshot.ts',
Expand All @@ -113,11 +113,11 @@ test('retired platform family implementations are rejected from src/platforms',
assert.deepEqual(
violations.map(({ file, rule }) => ({ file, rule })),
[
{ file: 'src/platforms/apple/core/apps.ts', rule: 'platforms-root-shape' },
{ file: 'src/platforms/harmonyos/app-lifecycle.ts', rule: 'platforms-root-shape' },
{ file: 'src/platforms/linux/snapshot.ts', rule: 'platforms-root-shape' },
{ file: 'src/platforms/vega/interactor.ts', rule: 'platforms-root-shape' },
{ file: 'src/platforms/web/provider.ts', rule: 'platforms-root-shape' },
{ file: 'src/platforms/apple/core/apps.ts', rule: 'retired-platforms-zone' },
{ file: 'src/platforms/harmonyos/app-lifecycle.ts', rule: 'retired-platforms-zone' },
{ file: 'src/platforms/linux/snapshot.ts', rule: 'retired-platforms-zone' },
{ file: 'src/platforms/vega/interactor.ts', rule: 'retired-platforms-zone' },
{ file: 'src/platforms/web/provider.ts', rule: 'retired-platforms-zone' },
],
);
});
Expand Down Expand Up @@ -602,23 +602,31 @@ test('Node resolves only each platform package root facade', () => {
}
});

test('the src/platforms root holds only the shared __tests__ directory', () => {
const clean = ['src/platforms/__tests__/install-source.test.ts'];
assert.deepEqual(checkPlatformsRootShape(clean), []);
test('the retired src/platforms zone rejects every production, test, and fixture file', () => {
const planted = [
'src/platforms/__tests__/install-source.test.ts',
'src/platforms/__fixtures__/snapshot.json',
'src/platforms/helper.mjs',
];
const found = checkRetiredPlatformsZone(planted);
assert.deepEqual(
found.map(({ file, rule }) => ({ file, rule })),
planted.map((file) => ({ file, rule: 'retired-platforms-zone' })),
);
});

test('a moved Android family cannot leave production or test files under the old root', () => {
const planted = [
'src/platforms/android/adb.ts',
'src/platforms/android/__tests__/snapshot.test.ts',
];
const found = checkPlatformsRootShape(planted);
const found = checkRetiredPlatformsZone(planted);
assert.deepEqual(
found.map(({ file, message }) => ({ file, message })),
planted.map((file) => ({
file,
message:
'the Android family has moved to packages/platform-android; remove the superseded src/platforms/android path',
'src/platforms is retired; family code belongs in its platform package, shared mechanics in an owning substrate package, and cross-family tests in their root or package test owner',
})),
);
});
Expand All @@ -629,14 +637,14 @@ test('a new direct production file or sibling directory under src/platforms fail
'src/platforms/common/util.ts',
'src/platforms/perf-utils.ts',
];
const found = checkPlatformsRootShape(planted);
const found = checkRetiredPlatformsZone(planted);
assert.deepEqual(
found.map(({ file }) => file),
planted,
);
for (const violation of found) {
assert.equal(violation.rule, 'platforms-root-shape');
assert.match(violation.message, /substrate package/);
assert.equal(violation.rule, 'retired-platforms-zone');
assert.match(violation.message, /src\/platforms is retired/);
}
});

Expand Down
Loading
Loading