commit da2945be8a1d46eee108dad789237db4ded722ac
parent 6bfa76f2f05ca01440363deda0edc12bb72d14dc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 7 Sep 2026 23:40:37 -0400
editor+plans: the arbiter's buttons are asserted gone, and the migration collapses a full kinds list the way arm does
Slice 1.3 review fix-up. Four things, none of them behaviour on a live lane.
THE RETIRED-ROUTES RULE HAD A HOLE THE SIZE OF THE ARBITER. `ArbiterBar` carried
`aria-label="Start the arbiter"` / `"Stop the arbiter"`, and its three tests were
deleted with it — so `grep -rn -i arbiter editor/e2e/` came back EMPTY and
nothing in the suite would have noticed a live Start button over a dispatcher
that never ran a unit in production coming back. The same hole was open on every
DISARM half: only `start digest sweep` / `start backfill sweep` were pinned gone,
so their stop twins and "Stop sweeping" could have survived over actions that no
longer exist. Both halves are asserted `toHaveCount(0)` now, on the board and on
the dashboard.
ONE COLLAPSE RULE, NOT TWO. `armLaneAction` collapsed a scope naming every
operation to `[]` — a leaf naming none draws the union, so it tracks the registry
rather than freezing today's three — and the migration carried `sweepKinds`
verbatim. Two answers to one question, through one leaf builder, which is
exactly what having one builder was supposed to prevent. It is `laneRootFromScope`'s
rule now, keyed on a new optional `available`.
`available` is OPTIONAL because `getSettings` cannot ask the registry:
`lib/operations.ts` imports the controller layer, and pulling that into every
reader of settings.json — the MCP server, the export build, every bin/ script —
to tidy one list would cost far more than the tidying is worth. Absent means no
collapse, which is a no-op on every settings file that exists: the live
`sweepKinds` is `[]`, and empty already meant the union. The numbers diff over
the live corpus is byte-identical to the post-1.3 run.
`LaneDeck`'s backfill card still explained itself in terms of "the corpus-wide
backfill sweep" and "at the default weight the lane is ALREADY idle-only". Both
retired — and the second was about to become misleading in a way that matters:
the yield is the operation's declared `contendsFor` now, which is NARROWER, so a
network-bound attribution run does not stand aside at all.
And the record: the trap about which writers must gain `held` in 1.4 was WRONG.
`armLaneAction` spreads `...policy` and overrides two keys, so it carries `held`
for free; `saveAutoQueueAction` is the one writer that spells every field out and
the one that must be edited. The as-shipped note also now says plainly that
folding the /jobs lane strip onto `deriveLaneState` changes RENDERED TEXT on a
live lane — a held transcription row reads Holding where it read Idle, and this
corpus has `transcriptionsPaused: true` — kept because the strip and the rail
disagreed until now, but not filed as a no-op. Plus what is dead and 1.4 deletes:
`AutoQueueReach`, its sanitizer, `AUTO_QUEUE_REACHES` and `OrderReach.tsx`'s
whole `reach !== null` branch.
common 890 tests (888 + two collapse cases), tsc clean in common and editor,
numbers diff unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 209 insertions(+), 50 deletions(-)
diff --git a/common/jobs/laneMigration.test.ts b/common/jobs/laneMigration.test.ts
@@ -215,6 +215,51 @@ test("a settings file with no digest or backfill block at all migrates to the la
// construction — both sides call `laneRootFromScope` — and this is what says so,
// so that a second leaf builder added later fails here rather than in
// production.
+test("a scope naming EVERY operation collapses to a leaf naming none", () => {
+ // The rule that makes a scope track the registry: a leaf naming no operation
+ // draws the lane's whole union, so "all three ticked" must not freeze today's
+ // three into the tree and silently exclude the fourth when it is enabled.
+ const all = ["diarization", "attribution-diarized", "attribution-text"];
+ assert.deepEqual(
+ laneRootFromScope("backfill", {
+ operations: all,
+ available: all,
+ }).children.map((c) => ("match" in c ? c.match : null)),
+ [{ type: "all" }],
+ );
+ // A STRICT subset still reaches the leaves.
+ assert.deepEqual(
+ laneRootFromScope("backfill", {
+ operations: ["diarization"],
+ available: all,
+ }).children.map((c) => ("match" in c ? c.match : null)),
+ [{ type: "all", operation: "diarization" }],
+ );
+ // And the migration collapses on the same rule, through the same builder —
+ // which is the point of it being one function rather than two.
+ const out = migrateSweepsToLanes(
+ { backfill: { sweepEnabled: true, sweepKinds: all } },
+ { backfill: all },
+ ).backfill as { root: { children: { match?: unknown }[] } };
+ assert.deepEqual(
+ out.root.children.map((c) => c.match),
+ [{ type: "all" }],
+ );
+});
+
+test("without an `available` list the migration carries the scope verbatim", () => {
+ // getSettings cannot ask the registry, so it passes none — and that is a
+ // no-op on every settings file that exists, because the live `sweepKinds` is
+ // empty and an empty list already means the union.
+ const out = migrateSweepsToLanes({
+ backfill: { sweepEnabled: true, sweepKinds: ["diarization"] },
+ }).backfill as { root: { children: { match?: unknown }[] } };
+ assert.deepEqual(
+ out.root.children.map((c) => c.match),
+ [{ type: "all", operation: "diarization" }],
+ );
+});
+
test("arming a lane with a scope equals migrating the same scope", () => {
const channels = ["teamrcn", "ObviousRises-rumble"];
const armed = laneRootFromScope("digest", { channels });
diff --git a/common/lib/laneMigration.ts b/common/lib/laneMigration.ts
@@ -44,6 +44,21 @@ import type { AutoQueueGroup, AutoQueueLeaf, AutoQueueMatch } from "./autoQueueT
export type LaneScope = {
channels?: readonly string[] | undefined;
operations?: readonly string[] | undefined;
+ // Every operation the lane currently dispatches, when the caller knows it.
+ //
+ // NAMING ALL OF THEM IS NOT THE SAME AS NAMING TODAY'S THREE: a leaf that
+ // names none draws the lane's whole union, so it TRACKS THE REGISTRY and an
+ // operation enabled later is picked up rather than silently excluded forever.
+ // So a scope covering everything collapses to no operation at all, and only a
+ // strict subset reaches the leaves.
+ //
+ // Optional because the settings migration's caller (getSettings) cannot ask
+ // the registry — `lib/operations.ts` imports the controller layer, and pulling
+ // that into every reader of settings.json to tidy one list would be a much
+ // larger change than the tidying is worth. Absent means "no collapse", which
+ // is a no-op on every settings file in existence: the live `sweepKinds` is
+ // empty, and an empty list already means the union.
+ available?: readonly string[] | undefined;
};
function slugs(value: unknown): string[] {
@@ -71,7 +86,10 @@ export function laneRootFromScope(
scope: LaneScope,
): AutoQueueGroup {
const channels = slugs(scope.channels);
- const operations = slugs(scope.operations);
+ const operations = collapseFullScope(
+ slugs(scope.operations),
+ slugs(scope.available),
+ );
const targets: AutoQueueMatch[] =
channels.length > 0
? channels.map((value) => ({ type: "channel" as const, value }))
@@ -100,6 +118,21 @@ export function laneRootFromScope(
};
}
+// A scope that names every operation the lane has means the same thing as a
+// scope that names none — see LaneScope.available. ONE definition, called by the
+// migration and by the editor's arm action through this one builder, so the two
+// cannot answer it differently.
+function collapseFullScope(
+ operations: readonly string[],
+ available: readonly string[],
+): string[] {
+ if (operations.length === 0 || available.length === 0) return [...operations];
+ const covers =
+ operations.length >= available.length &&
+ available.every((id) => operations.includes(id));
+ return covers ? [] : [...operations];
+}
+
type RawRecord = Record<string, unknown>;
function asRecord(value: unknown): RawRecord {
@@ -115,7 +148,13 @@ function asRecord(value: unknown): RawRecord {
// Takes the PARSED FILE, not the merged settings object, because "absent from
// the file" is the whole trigger and a merged object has already had the
// defaults folded in. Returns a new object; the input is never mutated.
-export function migrateSweepsToLanes(parsed: unknown): RawRecord {
+export function migrateSweepsToLanes(
+ parsed: unknown,
+ // The operations each lane dispatches, when the caller can ask the registry.
+ // See LaneScope.available for why this is optional and why absent is a no-op
+ // on every settings file that exists.
+ laneOperations: Partial<Record<"digest" | "backfill", readonly string[]>> = {},
+): RawRecord {
const file = asRecord(parsed);
const autoQueue = { ...asRecord(file.autoQueue) };
@@ -152,6 +191,7 @@ export function migrateSweepsToLanes(parsed: unknown): RawRecord {
// operation draws. So an empty list produces plain channel leaves and a
// non-empty one produces a leaf per (channel, operation).
operations: slugs(backfill.sweepKinds),
+ available: laneOperations.backfill,
}),
};
}
diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx
@@ -15,7 +15,7 @@ import { armLaneAction, disarmLaneAction } from "../../jobs/actions";
// The four lanes, and the ONLY place lane → server-action wiring lives. Rendered
// identically by the dashboard band and the monitor widget's controls section,
// which is what stops the two surfaces from growing different ideas about what
-// a lane can do (the widget could not start or stop a sweep at all, and had no
+// a lane can do (the widget could not arm or disarm a lane at all, and had no
// digest control whatsoever).
//
// Every lane's gate reaches the wire as `held`, computed once by isGateHeld; no
@@ -82,10 +82,10 @@ export function LaneDeck({
// THE DENOMINATOR IS ELIGIBLE VIDEOS, NOT EVERY VIDEO DIRECTORY. `videos`
// counts every directory in the corpus, ~1,700 of which have no transcript or
// are marked untranscribable and so can never carry a digest — against that,
- // this figure could not reach 100% however long the sweep ran. `eligible`
+ // this figure could not reach 100% however long the lane ran. `eligible`
// comes from the digest operation's own work list (transcribed, not
// untranscribable, not waiting on transcription), so it is the same population
- // the sweep actually walks.
+ // the lane actually walks.
//
// It falls back to `videos` while null, which is the state until every
// channel's snapshot has been regenerated — see the sync payload.
@@ -213,12 +213,12 @@ export function LaneDeck({
//
// Sweep on/off — is there a corpus-wide backfill at all. Persisted, so a
// server restart resumes it (editor/instrumentation.ts).
- // Pause/resume — hold a running sweep at zero throughput without ending it.
+ // Pause/resume — hold a running lane at zero throughput without ending it.
// The batch's limit() returns 0, which makes the pool idle-WAIT rather
// than finish, so resuming costs nothing and re-derives nothing.
//
- // Stopping the sweep drains rather than cancels: the channel in flight
- // finishes instead of losing a part-generated video.
+ // Stopping the lane drains rather than cancels: the video in flight finishes
+ // instead of being thrown away part-generated.
const digestArmed = digest?.armed ?? false;
const digestPaused = digest?.held ?? false;
const digestControls: LaneControl[] = [
@@ -283,7 +283,7 @@ export function LaneDeck({
<>
{/* The coverage percentage stays on the FIGURE rather than being
restated here — a card that says a thing twice is the accessory
- to remove. What the detail adds is the reach (a sweep can be a
+ to remove. What the detail adds is the reach (a lane can be a
third of the way through the corpus and have touched every
channel, or the reverse) and the two reasons a video is not in
the numerator at all. */}
@@ -316,17 +316,19 @@ export function LaneDeck({
);
// ── Backfill ──────────────────────────────────────────────────────────────
- // Arm / disarm the corpus-wide backfill sweep, and hold it without ending it.
+ // Arm / disarm the backfill lane, and hold it without switching it off.
//
- // TWO CONTROLS, AND THIS REVISED AN EARLIER DECISION. The backfill lane used
- // to carry only the sweep, on the reasoning that at the default weight the
- // lane is ALREADY idle-only — standing aside whenever transcription works — so
- // a manual pause looked redundant.
+ // TWO CONTROLS, AND THIS REVISED AN EARLIER DECISION. The lane used to carry
+ // only the arm switch, on the reasoning that it stands aside whenever
+ // transcription works anyway — so a manual pause looked redundant.
//
- // That covered the wrong hazard. Idle-only yielding handles "get out of the
- // transcription lane's way"; it does nothing for "this is a desktop someone is
- // sitting at, and diarization pins four cores for hours." Backfill work is
- // CPU-bound and long — a diarization pass over the corpus runs for days — so
+ // That covered the wrong hazard, and the yield is narrower than it looked: it
+ // applies to a run that would CONTEND for the GPU (the operation's declared
+ // `contendsFor`, since slice 1.3), so a network-bound attribution run does not
+ // stand aside at all. Even where it does, standing aside handles "get out of
+ // the transcription lane's way"; it does nothing for "this is a desktop
+ // someone is sitting at, and diarization pins four cores for hours." Backfill
+ // work is CPU-bound and long — a diarization pass over the corpus runs for days — so
// the operator needs a hold for reasons the scheduler cannot see.
//
// The pause writes THE SAME FIELD the Settings checkbox writes
@@ -335,15 +337,14 @@ export function LaneDeck({
// backfill.spec asserts the settings field through this button's label rather
// than just watching the label flip.
//
- // Stopping the SWEEP, by contrast, drains rather than cancels: the video in
- // flight finishes instead of being thrown away, and the stop reaches the
- // per-channel job the sweep is waiting on rather than meaning "after this
- // channel". Pause when you want it back; stop when you don't.
+ // Stopping the LANE, by contrast, drains rather than cancels: the video in
+ // flight finishes instead of being thrown away. Pause when you want it back;
+ // stop when you don't.
//
- // THE TWO NOW USE DIFFERENT VERBS, which is the cheapest fix for the hazard
- // this comment has been describing in prose. "Start sweep" and "Pause
- // Backfill" are the same shape of phrase for two acts whose costs to undo
- // differ by a week of GPU time. The feed SWEEPS; the gate HOLDS. The
+ // THE TWO USE DIFFERENT VERBS, which is the cheapest fix for the hazard this
+ // comment has been describing in prose. "Run the lane" and "Pause Backfill"
+ // would otherwise be the same shape of phrase for two acts whose costs to undo
+ // differ by a week of GPU time. The switch RUNS; the gate HOLDS. The
// aria-labels are untouched — they are internal addressing that confuses
// nobody, and the e2e suite finds these buttons by them.
const backfillArmed = backfill?.armed ?? false;
@@ -456,14 +457,14 @@ export function LaneDeck({
) : undefined
}
// TWO NOTES, NOT ONE SENTENCE. "Here is what this lane holds" and "the
- // sweep is armed while the lane is off" are different conditions, and
+ // lane is armed but held" are different conditions, and
// folding them together loses the one that is a problem.
note={
<>
{backfillAvailable && laneOperations.length > 1 && (
// THE ONE PLACE THE LANE IS STILL NAMED AS A LANE, and it earns it:
// that these operations share a queue and a pause is a true,
- // load-bearing fact — the sweep and the pause are two controls, and
+ // load-bearing fact — the arm switch and the pause are two controls, and
// conflating them is how an operator loses a week of GPU time. So
// it is said once, here, where both controls are, and the members
// are LISTED rather than hidden behind the queue key. Everywhere an
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -196,12 +196,6 @@ export async function armLaneAction(
`A lane armed on it would run forever without doing anything.`,
};
}
- // TICKING EVERY OPERATION IS NOT THE SAME AS NAMING TODAY'S THREE. A leaf
- // that names none draws the lane's whole union, so it tracks the registry:
- // an operation enabled later is picked up rather than silently excluded
- // forever. Only a STRICT SUBSET goes onto the leaves.
- const scoped =
- operations.length > 0 && operations.length < known.size ? operations : [];
const policy = settings.autoQueue[lane];
await writeSettings({
...settings,
@@ -210,10 +204,15 @@ export async function armLaneAction(
[lane]: {
...policy,
enabled: true,
+ // TICKING EVERY OPERATION IS NOT THE SAME AS NAMING TODAY'S THREE,
+ // and `available` is what says so — the collapse lives in the leaf
+ // builder, with the settings migration, rather than being written
+ // once here and once there.
root: scope
? laneRootFromScope(lane, {
channels: scope.channels,
- operations: scoped,
+ operations,
+ available: [...known],
})
: policy.root,
},
diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts
@@ -1213,6 +1213,17 @@ test("UI: each operation page carries its own lane, and the rail as context", as
const digest = page.locator('section[data-lane="digest"]');
await awaitHydration(digest);
await expect(page.locator("section[data-sweep-lane]")).toHaveCount(0);
+ // THE ARBITER'S BUTTONS, ASSERTED GONE. Its three tests were deleted with it,
+ // so without this nothing in the suite would notice `ArbiterBar` coming back —
+ // and it was a live Start button over a dispatcher that never ran a unit in
+ // production. The board is where it sat, so the board is where it is missed.
+ await page.goto("/operations");
+ await awaitHydration(page.locator('[data-board="operations"]'));
+ await expect(
+ page.getByRole("button", { name: /the arbiter/i }),
+ ).toHaveCount(0);
+ await page.goto("/operations/digest");
+ await awaitHydration(digest);
await expect(
digest.getByRole("heading", { name: "Auto-digest" }),
).toBeVisible();
@@ -1224,10 +1235,18 @@ test("UI: each operation page carries its own lane, and the rail as context", as
await expect(
digest.getByRole("button", { name: "Pause Digests" }),
).toBeVisible();
- // The sweep's arm button is gone from this page and from everywhere else.
+ // The sweep's own controls are gone from this page and from everywhere else —
+ // BOTH halves. Asserting only the arm half would let a disarm button survive
+ // over an action that no longer exists.
await expect(
page.getByRole("button", { name: "Start Digest sweep" }),
).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Stop Digest sweep" }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /Stop sweeping/i })).toHaveCount(
+ 0,
+ );
});
test("the digest lane offers Shortest first, and has no Reach axis", async ({
diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts
@@ -425,10 +425,21 @@ test("the lane arms from the dashboard and disarms without losing its rules", as
)
.toEqual({ enabled: false, rules: 1 });
- // The sweep's own button is gone from this surface, not merely relabelled.
- await expect(
- page.getByRole("button", { name: "start backfill sweep" }),
- ).toHaveCount(0);
+ // The sweep's own controls are gone from this surface, not merely relabelled
+ // — BOTH halves, and both aria-labels, because the deck drew whichever half
+ // matched the state and a test that only names the arm half would miss a
+ // disarm button left over an action that no longer exists.
+ for (const name of [
+ "start backfill sweep",
+ "stop backfill sweep",
+ "start digest sweep",
+ "stop digest sweep",
+ ]) {
+ await expect(page.getByRole("button", { name })).toHaveCount(0);
+ }
+ await expect(page.getByRole("button", { name: /Stop sweeping/i })).toHaveCount(
+ 0,
+ );
});
// (8c) THE SCOPE IS SET FROM A SCREEN, AND IT IS A TREE.
diff --git a/plans/one-core-phase-1.md b/plans/one-core-phase-1.md
@@ -910,11 +910,16 @@ would make every later slice's evidence unreadable.
- **`buildActiveJobs.buildLanes` takes no argument now.** It counted in-flight
work on the sweep lanes by scanning the job rows for their queue keys; four
runner rows read `getAutoRunnerStatus(lane).inFlight`, which is in-memory.
- **A held transcription or download lane reads `Holding` on the /jobs strip
- now**, where the two runner rows could only say running / idle / unavailable —
- the strip and the operations rail (which has derived it that way since slice 7)
- finally agree. One fold, one answer; that is what folding the two halves
- together buys.
+
+ **THIS CHANGES RENDERED TEXT ON A LIVE LANE, and the plan did not ask for it.**
+ The four rows now fold `gateHeld` through `deriveLaneState`, so a held
+ transcription or download row on the /jobs strip reads **Holding** where it
+ read *Idle* — and the live `settings.json` has `transcriptionsPaused: true`, so
+ this is what the operator will actually see. It is kept rather than reverted
+ because the strip and the operations rail (which has derived it that way since
+ slice 7) disagreed about the same lane until now, and one fold cannot answer
+ twice. Said plainly here rather than filed as a no-op, because "no rendered
+ number moved" is a claim this slice otherwise makes and this is the exception.
- **The sweep panel's surviving prose moved rather than died.** The band, the
"this operation is switched off" warning and the "shares one lane, one runner
and one pause with …" sentence are a `LaneOperationContext` **div** above the
@@ -984,6 +989,41 @@ first spec — the exact failure `plans/FACTS.md` documents for the primary
checkout, arriving by a route that entry did not cover. The two commits were
rewritten to drop it and FACTS.md now says so. Add by path in this repo.
+### The fix-up, after review
+
+The commit carrying this section, and so unable to name its own sha — the same
+knot the 1.2 note hit. Four things, none of them behaviour on a live lane:
+
+- **The arbiter's buttons are asserted GONE.** `ArbiterBar` carried
+ `aria-label="Start the arbiter"` / `"Stop the arbiter"` and its three tests
+ were deleted with it, so `grep -rn -i arbiter editor/e2e/` was EMPTY — nothing
+ would have noticed a live Start button over a dispatcher that never ran a unit
+ coming back. Same hole on the DISARM halves: only `start … sweep` was pinned
+ gone, so `stop digest sweep`, `stop backfill sweep` and *Stop sweeping* could
+ have survived over actions that no longer exist. Both halves, both surfaces,
+ now `toHaveCount(0)`.
+- **The full-scope collapse moved into `laneRootFromScope`.** `armLaneAction`
+ collapsed a scope naming every operation to `[]` (so the leaf tracks the
+ registry) and the migration did not, which is two answers to one question
+ through one builder. It is one rule now, keyed on a new optional
+ `available` — passed by the arm action, absent from `getSettings`, which cannot
+ ask the registry without pulling the controller layer into every reader of
+ settings.json. Absent is a no-op on every settings file that exists: the live
+ `sweepKinds` is `[]`, and empty already meant the union. **Numbers diff
+ unchanged.**
+- `LaneDeck`'s backfill card still explained itself in terms of "the corpus-wide
+ backfill sweep" and "at the default weight the lane is ALREADY idle-only".
+ Both retired; the yield is the operation's declared `contendsFor`, which is
+ NARROWER than the old sentence claimed — a network-bound attribution run does
+ not stand aside at all.
+- The trap below about which writers must gain `held` was wrong. Corrected.
+
+**Dead after this slice, and 1.4 deletes them:** `AutoQueueReach`,
+`sanitizeAutoQueueReach` and `AUTO_QUEUE_REACHES` in
+`jobs/autoQueuePolicy.ts`/`lib/autoQueueTypes.ts` have no reader left, and
+`OrderReach.tsx`'s whole `reach !== null` branch (the select, `REACH_LABEL` and
+two of its four sentences) is unreachable — every caller passes `null`.
+
### Traps for the 1.4 implementer
- **Every GATE read of the four legacy fields is inside `lib/pauseGates.ts`** —
@@ -1004,11 +1044,15 @@ rewritten to drop it and FACTS.md now says so. Add by path in this repo.
`backfill.spec.ts` (`backfill.enabled`, three tests, via its `laneEnabled`
helper) and `widget.spec.ts`.
- **`held` should land on `AutoQueuePolicy`, beside `enabled` and `snoozeUntil`,
- and `sanitizePolicy` is the one place to default it.** `armLaneAction` and
- `saveAutoQueueAction` both spell every policy field EXPLICITLY when they write
- — the note on `saveAutoQueueAction` says why — so both must gain the key or a
- save will silently unhold a lane. That is two call sites, and they are the whole
- list.
+ and `sanitizePolicy` is the one place to default it.** **ONE writer has to gain
+ the key: `saveAutoQueueAction`** (`editor/app/operations/actions.ts:~47`),
+ which spells every policy field EXPLICITLY — its own comment says why, and that
+ is exactly the hazard: a field it forgets is dropped on every save. Its
+ neighbours are safe by construction — `armLaneAction` and `disarmLaneAction`
+ (`editor/app/jobs/actions.ts:~203`) SPREAD `...policy` and override only
+ `enabled` and `root`, so they carry `held` for free — and so does
+ `snoozeAutoQueueAction`. An earlier draft of this note said all of them spell
+ the fields out; it was wrong, and the difference is the whole trap.
- **The numbers script already prints `held <lane>` for all four lanes** through
`isGateHeld`, so 1.4's before/after diff over the live corpus is a direct test
of the fallback: it must stay `true/false/false/false` while the file carries