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
132 changes: 103 additions & 29 deletions docs/adr/0027-descriptor-root-vs-eager-closure-budget.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
# ADR 0027: Descriptor Root Size vs Eager-Closure Budget (Proposed)
# ADR 0027: Descriptor Root Size vs Eager-Closure Budget

## Status

Proposed (2026-09-23). This ADR **decides nothing**. It records a conflict between two invariants
that are both enforced and both correct, and states the one question a repo owner must answer before
either invariant can be relied on for module placement. Until then, follow both: do not split the
descriptor root, and do not add an approval path to the budget gate to let the split through.
Accepted (2026-10-07, #2469). The conflict below is resolved in favor of allowing
**byte-neutral (statement-neutral) re-homing**: the eager-closure budget's NO-GROWTH rule now
tolerates exactly the split shape — the head closure still evaluates every merge-base module,
every module it newly evaluates is new to the merge-base tree and to its deleted sources (a
`git mv` inside the base closure stays the same module through rename canonicalization, which
preserves modules already in the closure and is no pass for a pre-existing external module
moved in), and the closure's total module-scope statement count does not grow. The rule lives
in `scripts/__tests__/closure-growth-rule.ts`, planted in every failing direction by
`scripts/__tests__/closure-growth-rule.test.ts`. The hard ADR-0019 checks (façade exactness, no
platform implementation before binding) are untouched: the tolerance relaxes the module-count
proxy, never the loading property.

## The conflict

Expand All @@ -26,16 +33,22 @@ Splitting a hub module necessarily adds static value edges beneath it, so every
reaches it grows by the number of new modules. The two rules have no shared approval path: one says
the file must be divided, the other says the division must not be visible to any importer.

## Rules at a glance, while unresolved

- Do not split `registry.ts`. The budget gate is the binding constraint and has no waiver.
- Do not add an approved-growth row, a baseline entry, or a suppression to pass it. That is the
allowlist `AGENTS.md` forbids.
## Rules at a glance, resolved

- A hub split is allowed when it is statement-neutral: every merge-base module stays in every
affected closure, every newly evaluated module is new to the merge-base tree and to its
deleted sources (rename canonicalization preserves modules already in the base closure — it
does not make a moved-in external module novel), and total module-scope statements do not
grow. The gate enforces this; an unexplained count growth still fails with no approval path,
as before.
- Do not add an approved-growth row, a baseline entry, or a suppression to pass the gate. That is
the allowlist `AGENTS.md` forbids; the split tolerance is a rule, not a waiver — it decides from
the two graphs, and nothing is hand-entered per split.
- Do not make registry construction asynchronous to dodge the walker. ADR 0008's synchronous root is
load-bearing for compile-time totality.
- Closure-neutral edits inside the descriptor root are still welcome: relocating guards beside the
union they constrain, correcting stale claims, and moving non-eager content out are fine so long as
every measured entry surface is unchanged.
- The split tolerance guarantees flat statement weight over a preserved closure; it does not
license adding eager *edges* under a façade — the head must still evaluate every merge-base
module, and the ADR-0019 checks stay hard.

## Measured

Expand Down Expand Up @@ -69,23 +82,84 @@ The cost is genuinely a *count*, not weight: the same bytes are evaluated either
makes the conflict worth deciding rather than absorbing silently, because the gate's proxy and the
debt rule's proxy disagree at exactly this shape.

## Decision required

May a byte-neutral re-homing of a hub module's contents into new modules raise the eager module count
of the entry surfaces that reach it — or must a hub module's contents stay inside the file its
consumers already evaluate?

Answering "the count may rise" needs a bounded, auditable growth allowance on an invariant that
currently refuses one. Answering "contents stay put" needs `AGENTS.md`'s 1,000-line rule to name the
exception for a hub whose consumers evaluate it as one unit, so the size is treated as accepted and
measured rather than as unaddressed debt. Either answer is fine; leaving it unstated means the next
agent at this file discovers the conflict from a red gate and picks one silently.
## Decision

May a byte-neutral re-homing of a hub module's contents into new modules raise the eager module
count of the entry surfaces that reach it? **Yes — exactly when it is a split**, decided by three
facts computed from the two closure graphs and the merge-base tree, not by an entered number:

1. **Containment:** the head closure evaluates every module the merge-base closure evaluated
(renames canonicalized), so nothing was dropped or swapped.
2. **Added-module novelty:** every module the head closure evaluates that the base closure did
not is absent from the merge-base tree at its rename-canonicalized path AND carries no code
from a source deleted since the merge-base — matched when the added module shares **two or
more identical normalized top-level statements** with one deleted source; a single shared
statement is treated as coincidence, because short constants (a schema version, a default
timeout) repeat across the repo and branding every reuse a move would poison new files).
A module the tree already had, sitting outside the closure, becoming eager — under its own
name, a renamed one, or a rewritten move that evades git's `-M` similarity pairing — is a
new edge, not re-homed code; and without this fact, weight deleted elsewhere in the closure
would fund it. Rename canonicalization preserves modules ALREADY IN the base closure; it is
not a pass for an external module moved in, which fails the check at the base path the
merge-base necessarily still has.
3. **Flat weight:** the closure's total top-level statement count — wiring lines excluded, so
re-homing is invisible to it — does not exceed the merge-base closure's.

This is issue #2469's candidate 4 (structure ∧ weight), with the structure half implemented as
containment + novelty rather than candidate 2's importer-set shape. Any subset is refutable —
containment without novelty accepts pulling a pre-existing module in at flat weight, novelty
without containment accepts dropping a merge-base module, weight without the other two accepts
unrelated shrinkage paying for a smuggled edge — and the three together admit healthy extraction
while refusing any growth in the closure's module-scope statement weight. Statements were chosen
over bytes because formatting and comment churn move bytes while statements track the thing being
preserved: module-scope declarations and calls. The guarantee is exactly **newly evaluated
modules are new code — new paths that share at most one coincidental statement with any source
deleted since the merge-base (`movedFromDeletedSource`'s deliberate two-statement move-signal
threshold) — and total statement weight is flat over a preserved closure**, not
equality of eager behavior: a split that REPLACES an existing statement with a more expensive
top-level call inside an existing file moves no count and no weight, so it passes — nothing
count-based, in modules or statements, can see a like-for-like replacement. That residue is
bounded by what the tolerance cannot hide: every module the head newly evaluates is a path the
merge-base tree did not have AND is not a verbatim transplant — the move probe above brands any
added module sharing two or more statements with one deleted source as pre-existing code.
The probe is deliberately conservative and textual:
code REWRITTEN or reformulated during a move does not match it and would pass as novel, so what
is verified is "no verbatim transplant of a deleted source," not "no repurposed logic" (the
weight fact is what still refuses a transplant made heavier). What keeps the residue small is
everything around the probe: the hard ADR-0019 checks (façade exactness,
no platform implementation before binding) stay count- and pattern-based under it, and the
added-module listing every failure prints is what a reviewer reads. The planted tests in
`scripts/__tests__/closure-growth-rule.test.ts` pin every failing direction.

Maintenance cost of an ordinary extraction under this rule: zero configuration edits. No row,
baseline, or number is touched per split — the merge-base comparison recomputes everything. (The
candidate-3 approval-row design instead costs one hand-edited row per split plus its retirement.)

The `AGENTS.md` 1,000-line rule now has no hub exception to name: a statement-neutral split is
shippable, so an oversized hub is addressable debt rather than accepted state.

## Alternatives considered and refuted

- **Approved-growth row mirroring `APPROVED_OVER_CEILING` (issue, reason, owner):** refuted pending the
decision above. It would be auditable, but it is an allowlist on the one invariant deliberately
given no approval path, and `AGENTS.md` forbids adding one to obtain a pass.
- **Approved-growth row mirroring `APPROVED_OVER_CEILING` (issue, reason, owner):** refuted. It
would be auditable, but it is an allowlist on the one invariant deliberately given no approval
path, `AGENTS.md` forbids adding one to obtain a pass, and every split would owe a row plus its
retirement — maintenance the chosen rule does not.
- **Structure alone (#2469 candidate 2):** refuted by the planted smuggle test — a "new" module can
carry a new heavy edge, so containment proves nothing about eager work.
- **Containment + weight without added-module novelty (the rule's first draft on #3298):** refuted
by the planted pre-existing-heavy test — deleting statements elsewhere in the closure funds a
static edge to a merge-base module that sat outside it, which is real growth, not a split.
- **Novelty by merge-base tree path alone (the rule's second draft on #3298):** refuted by the
planted rewritten-move test — a move that changes the path and churns formatting reports to
git as delete + add, so an absent destination is not proof of new code. The deleted sources'
statement texts close it down to a stated threshold: two identical normalized statements from
one deleted source is the move signal, one is tolerated as coincidence (short constants repeat
across the repo), and a transplant rewritten statement-by-statement stays outside a textual
probe — what remains heuristic-free is the LIST of deleted sources (git's own delete listing,
needing no similarity pairing to have been attempted), not the
content match.
- **Weight alone (#2469 candidate 1):** refuted by the planted drop test — a swap can keep the
statement total flat while quietly making the closure evaluate different modules.
- **Leave the array in place:** the status quo, not a resolution — it leaves the size debt unowned and
the conflict undocumented.
- **Function-scoped `await import` per family:** passes the gate by making registry construction
Expand All @@ -99,5 +173,5 @@ agent at this file discovers the conflict from a red gate and picks one silently

A complete, byte-faithful split of the descriptor array exists on
`refactor/collocation-registry-family-split` (`40bf719a8b`, split commit `ad3aaa520e`) and is pushed.
It is kept rather than deleted so that answering the question above does not require redoing the
mechanical work; 80 descriptors, 79 byte-identical including comments.
With this decision accepted, that branch is now shippable: its +11 is a statement-neutral split of
exactly the tolerated shape, so it needs no gate change beyond this one.
9 changes: 5 additions & 4 deletions docs/adr/0028-capability-family-cell-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ family rather than arguing from the shape:
2. Report the change in files-touched for that family against the recorded baseline of the four
recurring eight-package commits.
3. Report eager-closure deltas per entry surface. `scripts/__tests__/eager-closure-budgets.ts` has no
growth-approval path, and [ADR 0027](0027-descriptor-root-vs-eager-closure-budget.md) shows a
byte-neutral re-home can be unshippable here. A row table is a **new eager value**; if it costs
closure slots, it is blocked on the same ground and this ADR should be withdrawn rather than
granted an exception.
growth-approval path; [ADR 0027](0027-descriptor-root-vs-eager-closure-budget.md) tolerates
module-count growth only when the head closure preserves every merge-base module, every newly
evaluated module is new to the merge-base tree (or a rename), and total module-scope weight does
not grow. A row table is a **new eager value**: it passes only by satisfying all three conditions;
otherwise the gate stays red and this ADR should be withdrawn rather than granted an exception.

If the fan-out does not visibly shrink, close this as rejected and leave the two authored lists in
place — a compile-checked duplicate is a legitimate resting state.
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
| [0024 Screen Recording — One Coordinator, Two Facts (Proposed)](0024-screen-recording-provable-signal.md) | `record start`/`stop` lifecycle, the export-exists / recorder-stopped split, backend stop-and-collect contract, recovery through the coordinator, the ADR 0019 §5 amendment and the failed-finish cleanup fix |
| [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, and why no pose-control command exists |
| [0026 Scroll Clip Authority — Ownership Is the Parent Edge](0026-scroll-clip-authority.md) | iOS scroll indicators and the visible band, why ownership is read from the parent edge instead of an ancestor walk, what may remove a node from an interactive snapshot, and ejection dispositions |
| [0027 Descriptor Root Size vs Eager-Closure Budget (Proposed)](0027-descriptor-root-vs-eager-closure-budget.md) | splitting `packages/command-registry/src/registry.ts`, the ADR-0019 eager-closure module-count budget, and why a byte-neutral hub split is currently unshippable |
| [0027 Descriptor Root Size vs Eager-Closure Budget](0027-descriptor-root-vs-eager-closure-budget.md) | accepted: the eager-closure budget tolerates a statement-neutral hub split only (containment + added-module novelty + flat top-level-statement weight) and nothing else; splitting `packages/command-registry/src/registry.ts` is unblocked |
| [0028 Capability-Family Cell Vocabulary — One Runtime Source (Proposed)](0028-capability-family-cell-vocabulary.md) | adding a capability operation family, `UnavailablePlatformRuntimeFacts` / `UNAVAILABLE_CELLS`, `INTERACTOR_OPERATIONS`, and why an eight-package fan-out recurs |
| [0029 Daemon Policy](0029-daemon-policy.md) | `AGENT_DEVICE_DAEMON_POLICY`, confining a daemon's commands, devices, or device shutdown, and where operator rules are enforced for batch/replay steps |
| [0030 Process Lock Exclusion](0030-process-lock-exclusion.md) | process-lock publication/reclaim/release, retained mutation guards, and the single-protocol upgrade boundary |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type { ExecOptions } from '@agent-device/host-kit/command';
import fs from 'node:fs';
import path from 'node:path';
import {
createRunnerPhaseBudget,
requireRunnerBuildSettingsMatchBuildLog,
diffComparableRunnerCacheMetadata,
resolveRunnerBundleBuildSettings,
Expand All @@ -18,8 +17,9 @@ import {
resolveRunnerSandboxBuildArgs,
resolveExpectedRunnerCacheMetadata,
resolveRunnerDerivedPath,
memoizedRunnerXcodeVersion,
} from '../runner-cache-metadata.ts';
import { createRunnerPhaseBudget } from '../runner-phase-budget.ts';
import { memoizedRunnerXcodeVersion } from '../runner-toolchain-probe.ts';
import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../apple-runner-platform.ts';
import {
appleToolchainProbeResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,8 @@ import { appleRunnerTestHost } from '../test-host.ts';
import { withAppleRunnerProvider } from '../runner-provider.ts';
import { classifyRunnerReportedError, type RunnerCommand } from '../runner-contract.ts';
import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts';
import {
createRunnerPhaseBudget,
requireRunnerPhaseRemainingMs,
resolveExpectedRunnerCacheMetadata,
} from '../runner-cache-metadata.ts';
import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';
import { createRunnerPhaseBudget, requireRunnerPhaseRemainingMs } from '../runner-phase-budget.ts';
import { captureDiagnostics } from './runner-session-fixtures.ts';
import {
startFakeRunnerServer,
Expand Down
Loading
Loading