commit cde4f0521f02dc8e1e42737a908c3d09ef260cc0
parent 66c121a27d667c40e3f834b7e8ab82add70b079a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 26 Aug 2026 00:58:43 -0400
digestActions: DigestLaneChoice is an alias, not a re-export
`export type { DigestLaneChoice }` in a `"use server"` file is type-correct —
tsc passes — but Next's server-actions transform does not erase a RE-EXPORT the
way it erases a type DECLARATION. It emits a runtime binding, and every route
that reaches jobReplayRegistry.ts 500s with
ReferenceError: DigestLaneChoice is not defined
Caught by e2e on the first spec; invisible to tsc and to the unit suites. An
alias declaration IS erased, so this keeps the single definition in
controller/digestTarget.ts without a second literal of the same two strings.
Also: plans/STATE.md gets its 2026-08-26 entry (and retires "Recommended next"
#6), plans/FACTS.md gets the slice-2 invariants — laneFor's optionality is
presence-significant, operationJobs.ts is the one runner path and does not
drain, GROUP_STAGES, and the four id spaces that still spell "backfill" —
plans/editor-operations-ia.md marks slice 2 shipped INCLUDING the three places
it planned differently from what shipped, and the editor CHANGELOG gets the
four user-visible entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 238 insertions(+), 22 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
+- **The channel page's "Backfill" card is the *Speakers* card, and it says what each operation costs.** "Backfill" is the name of a *queue*, and on this install that one word was standing in for three operations with different inputs, different costs and different reasons to be switched on — nobody can arm, pause or run "a backfill". The card is now named after the work it holds, one row per operation, each row carrying what one unit of it costs. Old links keep working: **`?stage=backfill` still opens the Speakers panel** rather than silently dropping you on the channel overview.
+- **A run's summary line reports everything that happened to it, wherever the run was started from.** Starting the same work from the channel card and from the corpus-wide sweep used to produce two differently-worded summaries: the card's mentioned videos that were *deferred* (over the length limit) and *blocked* (waiting on an earlier operation) but not the ones *skipped* mid-rewrite; the sweep's did the opposite. Both now print all of them, so "what did that run actually do" has one answer. The needs-re-acquiring count stays its own separate number and is never folded into the rest — on this corpus it is ~91× larger, and one summed "remaining" figure would be useless.
+- **A GPU-bound speaker run is now scheduled as GPU-bound.** When diarization is configured to run on the Vulkan backend it holds ~4.4 GB of the same card the transcription engine wants, and it *declared* that — but the dispatcher never asked, and scheduled it as if it only wanted CPU cores. It now stands aside for transcription the way it was always meant to. The same fix means a **metered** digest run is reserved on the metered lane rather than the local one, so the two digest lanes genuinely run side by side.
+- **An operation the console cannot drive now says so, instead of showing you somebody else's controls.** `/operations/<id>` worked out which console to draw by testing the operation's name against a short list. Anything not on that list got the shared speaker-work sweep's console — its plan, its scope, and a **live Start button that arms a corpus-scale run** — under the wrong heading. It reads the operation's own registration now, and an operation that neither a runner nor a sweep dispatches gets a panel saying what its inputs are and what does dispatch it.
- **The auto-queue console is `/operations`: a board over every operation, and a page for each one.** The old page put a rail across the top saying what all four pipelines were doing, and then a **Lane dropdown** decided which one you could act on. A dropdown is not an address — you could not link a colleague to the download lane, Back did not return to it, and the two panes it switched between had to stay mounted so an unsaved policy edit would survive the switch. The rail's rows are links now: `/operations` is the board (every pipeline, one line each, plus the arbiter and a sync row), and `/operations/<id>` is one operation in full — the runner's rules and Start/Drain/Stop for **Download** and **Transcription**, the sweep, the pause and the itinerary for **Digest** and each **Speaker** operation. Two things follow from one page per operation: a speaker operation's own band is drawn on its own page (the shared panel could only say "this lane covers several operations", because summing an audio pass and a per-chunk model call gives a figure in no unit), and the page says in a sentence that the lane, the sweep and the pause it shows are **shared** with the other speaker operations — a per-operation page is not a per-operation switch. **`/auto-queue` redirects**, so every bookmark still lands; the API paths under `/api/auto-queue/*` never moved. The sweep scope is deliberately **not** pre-ticked to the operation you are looking at: the scope is written at arm time, and a helpful default would arm a narrower sweep than the operator can see.
- **The sidebar is four groups — Corpus, Operations, Sites, Machine — instead of three, and "Pool" is gone.** Nineteen links in a group called *Pool* that held ten unrelated tools was the shape of the problem, not a labelling accident: the editor has four nouns, and the nav is where that is either true or not. *Monitor* leaves the nav entirely — the widget is a **projection** of the board's own payload drawn for a second screen, and a projection is not a place; it is reached from the **Pipeline** band on the dashboard, which is the thing it mirrors. Nothing was deleted: the interim entries (Actionable, Schedule, Charts, Search aliases, Deploy, Build, Homepage) sit in the group that will absorb them, and the rule for what comes next is umtool's, quoted where it can be read: fold under an entry rather than add beside it, and retire a route with a redirect rather than a 404. The full model, and the eight slices still ahead of this one, are in `plans/editor-operations-ia.md`.
- **`/channels` is sectioned by the groups a site files its channels into, and each section can run the pipeline over its own shelf.** Every site already sorts its channels into authored groups — on Jeralyzer they are *Archives* ("Channels that archive Jeremy's content, out of his reach"), *Guest Appearances* and *Extended Universe* — and the editor showed none of it: it scoped the rows to the active site, threw the grouping away, and rendered one flat table. Those descriptions were written on the Sites page, stored, and rendered **nowhere**. The table is now one table sectioned by group — columns stay locked across every section, because comparing transcript counts between groups is the point — with each group's name, its description, and an *off by default* note when visitors don't get that group preselected. Sorting a column sorts within each group; a **Group by section** tick flattens it again without leaving the site. Under *all sites* the table stays flat, since groups only partition one site's channels and there is no single grouping across the pool.
diff --git a/editor/app/channels/[slug]/digestActions.ts b/editor/app/channels/[slug]/digestActions.ts
@@ -32,13 +32,20 @@ import {
} from "yt-dlp-transcript-common/lib/digest";
import path from "node:path";
import { runDigestChannelJob } from "yt-dlp-transcript-common/controller/operationJobs";
-import type { DigestLaneChoice } from "yt-dlp-transcript-common/controller/digestTarget";
+import type { DigestLaneChoice as CommonDigestLaneChoice } from "yt-dlp-transcript-common/controller/digestTarget";
-// RE-EXPORTED, not redeclared: the definition lives in
-// controller/digestTarget.ts, and this alias is what the client components have
-// always imported. Two declarations of the same two strings is how they would
-// drift.
-export type { DigestLaneChoice };
+// AN ALIAS, NOT A RE-EXPORT, and the difference is load-bearing in a
+// `"use server"` file. `export type { DigestLaneChoice }` is type-correct — tsc
+// passes — but Next's server-actions transform does not erase a re-export the
+// way it erases a type DECLARATION: it emits a runtime binding, and every route
+// that reaches jobReplayRegistry.ts 500s with
+// `ReferenceError: DigestLaneChoice is not defined`. Caught by e2e, invisible to
+// tsc.
+//
+// So: an alias declaration, which IS erased, pointing at the one definition in
+// controller/digestTarget.ts. Not a second literal — two declarations of the
+// same two strings is how they would drift.
+export type DigestLaneChoice = CommonDigestLaneChoice;
// Run the digest lane over one channel, from its stage card.
//
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -2089,3 +2089,78 @@ already recorded for `community-notes/v2fkbw7` → `v2cywen` under Phase 2.)
**`editor`'s `lint` script has no config** — `eslint` v9 finds no `eslint.config.*` in
`editor/` or the root; the root `lint` delegates to `export`. `tsc` + e2e are the editor's
verification, not lint.
+
+## Verified 2026-08-26 — editor IA slice 2, the operation runner and the lane resolver
+
+Facts a cold agent would otherwise re-derive from the slice-2 commits (`1da6f22` → `1058b98`).
+Commit bodies carry the reasoning.
+
+**`BackfillKind.laneFor?` is OPTIONAL AND ITS PRESENCE IS SIGNIFICANT** (`common/lib/backfillKinds.ts:387`).
+`common/controller/backfillBatch.ts` keys the GPU idle-only rule off `k.laneFor &&`, i.e. off
+the field EXISTING, as the marker for "this kind's scarce resource depends on settings, so
+nothing else is deciding it". "Give every kind a `laneFor` defaulting to `lane`" is therefore
+NOT a no-op refactor: it enrols every kind in that rule and makes the backfill lane idle-only
+whenever a statically GPU-bound kind is in the run — the exact regression the guard's comment
+records. Add one only where the lane genuinely varies. Digest and diarization declare one; no
+other kind does.
+
+**Digest declaring a `laneFor` does not put it in that guard**, and not because of the guard:
+`resolveBackfillKinds` filters through `laneBackfillKinds` (BACKFILL_QUEUE only), so digest can
+never reach `backfillBatch`'s `kinds` array even when asked for by name. `backfillKinds.test.ts`
+pins it.
+
+**`laneForOperation` lives in `common/controller/operationLane.ts`**, alone, because the arbiter
+plans units against it and `operationJobs.ts` reserves a queue key with it — while the arbiter
+calls `operationJobs`. In `arbiter.ts` that was an import cycle.
+
+It asks `getBackfillKind`, **NOT `operationCatalog()`**, and that is load-bearing:
+`getBackfillKind("download")` is `undefined`, so download and transcription get no lane and
+`planArbiterUnits` skips them. A catalog lookup would hand them a lane and the arbiter would
+start a backfill channel job for work no backfill kind can do.
+
+It also **does not consult `enabled`** and never has. A switched-off kind still resolves a lane;
+what keeps it out of the arbiter is the `enabled` set in `runArbiterPass`, which projects an
+empty id list. Teaching the resolver to return null would look like a tightening and would be a
+second, redundant gate that label-only callers would read as "no lane at all".
+`common/controller/laneForOperation.test.ts` pins the boundary.
+
+**`common/controller/operationJobs.ts` is the ONE per-channel runner path** — a hand-clicked run,
+a swept one and an arbiter-dispatched one are the same function. `runBackfillChannelJob`,
+`runDigestChannelJob`, and `runOperationChannelJob(operation, …)` over both.
+
+**They do NOT drain.** They return the `StreamActionResult` and the three callers that want
+sequencing (`backfillSweep`, `digestSweep`, `arbiter`) `await drainStream(result.stream)`
+themselves. A runner that drained would consume the stream the editor's `StreamActionLog`
+renders as a live log, and every stage card's log would come up dead. This is the single most
+likely thing to be "fixed" back.
+
+**`onDone?: () => void` exists because `common/` cannot import `next/cache`.** The editor's
+server actions pass `() => revalidatePath(...)`; the runner calls it at job end.
+
+**`GROUP_STAGES`** (`editor/app/channels/[slug]/lib/stageStatus.ts`) is an exhaustive
+`Record<OperationGroup, readonly StageId[]>` spread in `OPERATION_GROUP_ORDER` to build the
+channel page's stage list. NOT 1:1 with groups — `media` owns `download` AND `transcode` — which
+is why it is a Record of arrays. It covers only the MIDDLE: configure/playlist and
+cleanup/diagnostics/danger are channel chores, not operations, and stay hand-listed. `tsc`
+checks membership; `stageOrder.test.ts` pins order and cardinality, which it does not.
+
+**`StageId` "backfill" is now "speakers"**, together with `FlowStationId`. `?stage=backfill`
+still resolves, via `STAGE_ALIASES` in `page.tsx` — an unknown `?stage=` falls back to the
+overview at 200, which is right for a retired stage and wrong for a renamed one. Four id spaces
+still spell "backfill" and each was decided, not missed: `StationId` (group buttons) and the
+`/actionable` section id wait for the vocabulary pass and slice 4; the widget's `SectionId` is
+persisted in user URLs and never changes; `snapshot.backfill` and `BACKFILL_QUEUE` are keyed by
+kind id / are a scheduler key and are correctly named.
+
+**The stage card's POPULATION aria-labels still say "backfill"** (`backfill reachable`,
+`backfill kind …`, `backfill blocked/deferred/needs re-acquiring`, `videos needing a backfill …`)
+— 16 spec lines, deferred to the vocabulary pass. Only the wrapper became `speakers section`.
+
+**`ExternalOperation.runner?: AutoQueueKind`** names the auto-queue runner that dispatches an
+operation. `dispatch` cannot answer it: download and transcription are `external` WITH a runner,
+a future `transcode` would be `external` with none. `/operations/<id>` reads it instead of
+testing the id — and reads `sweepLaneIdFor(op)` (off `lane.queueKey`, in
+`editor/app/operations/lanes.ts`) for the sweep lane, which returns **null** for an operation
+neither sweep runs. Null renders a "no console here" panel; the id-shaped predecessor would have
+rendered the backfill sweep's console — with a live Start button arming a corpus-scale sweep —
+under a `transcode` heading.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,8 +3,9 @@
The working memory for the local-AI derived-corpus work. Rewritten at the end of every
session, before context is cleared. See [`README.md`](README.md) for the protocol.
-**Last updated:** 2026-08-25 — editor IA slice 1 (`/operations`) landed; see the dated
-section below. Previously: 2026-08-24 — **`main` is green again, `feat/channel-groups` is merged, and
+**Last updated:** 2026-08-26 — editor IA slice 2 (speakers are a real stage; one runner
+path) landed, and slice 1 was committed with it. See the two dated sections below.
+Previously: 2026-08-24 — **`main` is green again, `feat/channel-groups` is merged, and
this file caught up with two sessions it had no entry for.** The 2026-08-12 session below
landed in `7d32438` on 08-18; everything from 08-19 to 08-24 — recency ordering, the
arbiter, lane guards, the pipelines band, worker tags and slots, LLM fan-out, unit
@@ -83,6 +84,77 @@ verified against the tree, **not started**, and its real prerequisite is operati
`sdb1` (1.8 T platter) is not mounted. **Not covered here:** `umtool` (27 commits on 08-18,
a separate workspace package) is documented in `umtool/docs` and AGENTS.md.
+### 2026-08-26 — speakers become a real stage, and there is one way to run an operation
+
+Six commits, `1da6f22` → `1058b98`, each its own reviewable diff.
+[`editor-operations-ia.md`](editor-operations-ia.md) has the full "Slice 2, as shipped"
+section; [`FACTS.md`](FACTS.md) has the invariants. Slice 1 is committed too (`9b65b83`) —
+it had been sitting uncommitted as 50 changed files, which would have made slice 2 one
+unreviewable diff.
+
+**Two live bugs fell out of the refactor, neither of them the point of it:**
+
+- **The arbiter never called diarization's `laneFor`.** `laneForOperation` special-cased
+ digest and returned the DECLARED `.lane` for everything else, so a sortformer/vulkan
+ diarization reserved as `contendsFor: "cpu"` while holding ~4.4 GB of the same 8 GB card
+ the transcription engine wants. Diarization has declared a `laneFor` since it was written;
+ nothing asked it. Now one resolution off the kind's own declaration, and digest gains the
+ `laneFor` it should always have had.
+- **`/operations/<id>` decided what to render from four hardcoded id tests.** A registered
+ `transcode` — external, no runner — would have drawn the BACKFILL SWEEP'S console under a
+ "Transcode" heading, with a live Start button arming a corpus-scale GPU commitment. The
+ descriptor states it now (`runner?`, and `sweepLaneIdFor` off `lane.queueKey`), and **null
+ is a real answer** rendered as a "no console here" panel.
+
+**One runner path.** `common/controller/operationJobs.ts`. There were four copies of "run one
+operation over one channel" and no two agreed: the editor's backfill summary line printed
+`deferred` and `blocked`, the sweep's printed `skipped`, and neither printed the other's — so
+"what did that run actually do" depended on which button started it.
+
+**The load-bearing decision in it: the runners do NOT drain.** Draining is the SWEEP's
+property, and the editor consumes the same stream client-side to draw a live log — a runner
+that drained would leave every stage card's log dead. The three callers that want sequencing
+drain at their own call site. This is the thing most likely to be "fixed" back.
+
+**`StageId "backfill"` → `"speakers"`.** It was a queue key wearing a stage's name; nobody can
+arm, pause or run "a backfill". `?stage=backfill` still resolves (`STAGE_ALIASES`) — an unknown
+`?stage=` falls back to the overview, which is right for a retired stage and wrong for a
+renamed one. **Two assertions would have passed vacuously after the rename** and were caught:
+a `notEqual` against a value the union can no longer hold, and a count-0 against a label
+nothing renders.
+
+**Decisions worth keeping:**
+- **`laneFor` must stay OPTIONAL.** `backfillBatch.ts` reads its PRESENCE as the marker for
+ the GPU idle-only rule, so "give every kind a `laneFor` defaulting to `lane`" is not a no-op
+ — it enrols every kind in that rule and revives a recorded regression.
+- **`laneForOperation` asks `getBackfillKind`, not `operationCatalog()`.** A catalog lookup
+ would hand download and transcription a lane and the arbiter would start a backfill channel
+ job for work no backfill kind can do.
+- **NOT one `OperationStage.tsx`**, which is what the slice plan said. The two cards overlap
+ in presentation and diverge in everything that acts; merging them means a component
+ branching on its own identity. `OperationWork.tsx` is the presentational half, and the
+ criterion is written into the file.
+- **The `/channels` backfill group button is the LANE, not one operation**, so it does not go
+ through the dispatcher.
+- **The population aria-labels still say "backfill"** — 16 spec lines, deliberately left for
+ the vocabulary pass to keep them out of this slice's e2e risk.
+
+**Recommended next**, unchanged from the 08-25 list except that #6 is done:
+
+1. **GPU yield on a quiet box** — still the gate on arming the sweep, still unmeasured.
+2. **Unified-ops step 1**: collapse `noDigest` into `snapshot.backfill.digest`. **Slice 4
+ needs this.**
+3. **Relocate `omnimirror`'s media** once the platter is mounted (131 GB off a 94%-full SSD).
+4. **Phase 6 Ollama `/ask`** — genuinely independent; a good parallel task.
+5. Decide `attribution-text`'s fate on that one channel: ~194,000 chunk-level calls is a
+ sweep-sized commitment that was never priced.
+6. **The vocabulary pass** — `BackfillKind` → `Operation`, `BackfillLane` → `Lane`,
+ `backfillKinds.ts` → `operations.ts`, plus the three id spaces slice 2 handed it
+ (`StationId`, the `/actionable` section id, the population aria-labels). Registering
+ `transcode` waits for it. Then **editor IA slice 3** (per-operation settings).
+
+---
+
### 2026-08-25 — the editor gets its noun: `/operations`, and a nav with four groups
**The backend found its organizing noun — the operation — and the UI had not.** Counted
@@ -129,11 +201,9 @@ describes the reasoning):
4. **Phase 6 Ollama `/ask`** — genuinely independent; a good parallel task.
5. Decide `attribution-text`'s fate on that one channel: ~194,000 chunk-level calls is a
sweep-sized commitment that was never priced.
-6. **Editor IA slice 2** — speakers become a real stage: `runOperationChannelJob()` extracted
- from the arbiter and shared, `laneFor` on every kind, one `OperationStage.tsx`, the stage
- list's middle derived from `OPERATION_GROUP_ORDER`. Slice 3 (per-operation settings)
- follows it; **slice 4 needs unified-ops step 1** and slice 9 needs the sweep to have run
- once, so those two wait on #1 and #2 above. See
+6. ~~**Editor IA slice 2**~~ — **DONE 2026-08-26**, see the section above. What it planned
+ and what shipped differ in three places (`laneFor` is not universal, there is no single
+ `OperationStage.tsx`, the backfill group button stayed the lane); the reasons are in
[`editor-operations-ia.md`](editor-operations-ia.md).
---
diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md
@@ -107,14 +107,25 @@ dependencies allow. Sizes are S/M/L.
1. **The frame — SHIPPED.** `/operations` (the board) + `/operations/<id>` (one operation),
four-group nav, `/auto-queue` → redirect. Deletes the lane `<select>`. UI-only. **M.**
-2. **Speakers become a real stage.** Extract `runOperationChannelJob()` from
- `arbiter.ts:226-240` and share it with `channels/groupActions.ts:162-175` and the channel
- page; `laneFor` on every kind; `DigestStage.tsx` + `BackfillStage.tsx` → one
- `OperationStage.tsx`; `StageId "backfill"` → `"speakers"`; the middle of `stageOrder`
- derived from `OPERATION_GROUP_ORDER`. **Keep `snapshot.backfill` on disk** — this is a UI
- rename, not a data migration. e2e: `helpers.ts:336-346` `ChannelStage`,
- `channelStage(SLUG, "backfill")` ×10 in `backfill.spec.ts` and ×3 in
- `attribution.spec.ts`, heading "Speaker work" at `attribution.spec.ts:347`. **M.**
+2. **Speakers become a real stage — SHIPPED** (`1da6f22` → `1058b98`; see "Slice 2, as
+ shipped" below). `common/controller/operationJobs.ts` is the one per-channel runner path,
+ `StageId "backfill"` → `"speakers"`, `GROUP_STAGES` derives the middle of `stageOrder`,
+ and `/operations/<id>` stops guessing which console to draw. `snapshot.backfill` is
+ untouched on disk — this was a UI rename, not a data migration. **M.**
+
+ Three things this bullet planned differently from what shipped, each for a reason:
+ - **`laneFor` is NOT on every kind.** It is on digest and diarization and must stay
+ optional: `backfillBatch.ts` reads its PRESENCE as the marker for the GPU idle-only
+ rule, so a universal `laneFor` would enrol every kind in it. See FACTS.md.
+ - **Not one `OperationStage.tsx`.** The two cards overlap in presentation and diverge in
+ everything that acts (digest holds four `useState`s, a lane-coupled queue-key follower,
+ a six-argument trigger and a second `StreamActionLog` on a third queue). Merging them
+ would mean a component branching on its own identity — the shape this document is
+ undoing. What was extracted is the presentational half, `OperationWork.tsx`, with two
+ thin trigger blocks passed as children.
+ - **The `/channels` backfill group button does NOT go through `runOperationChannelJob`.**
+ That station is the LANE, not one operation on it — `KIND_FOR` says so by mapping it to
+ the lane's job kind — so it still runs every enabled lane kind.
3. **Per-operation settings on the operation page.** Move the Digest
(`SettingsForm.tsx:526`), Diarization (`:708`), Speaker-work lane (`:889`) and Speaker
attribution (`:982`) fieldsets to `/operations/<id>`; split `saveSettingsAction`
@@ -154,7 +165,11 @@ dependencies allow. Sizes are S/M/L.
`instrumentation.ts:91-122`. **L, high risk — and not before the sweep has run once for a
day and been watched** (STATE.md "Recommended next" #1: GPU yield on a quiet box). **L.**
-**Vocabulary renames follow slice 2, mechanically and in one commit:** `BackfillKind` →
+**Vocabulary renames follow slice 2, mechanically and in one commit** (slice 2 has shipped, so
+this is now the next mechanical pass; it also inherits from slice 2 the `StationId` rename, the
+`/actionable` section id, and the stage card's population aria-labels — `backfill reachable`,
+`backfill kind …`, `backfill blocked/deferred/needs re-acquiring`, `videos needing a backfill …`
+— which were deliberately left spelling "backfill" to keep 16 spec lines out of that slice)**:** `BackfillKind` →
`Operation`, `BackfillLane` → `Lane`, `backfillKinds.ts` → `operations.ts`, and "unit"
disambiguated (a worker-pool unit is not a cost unit). Doing them earlier would collide with
every slice above; doing them later means writing `backfill` in surfaces that no longer say it.
@@ -190,3 +205,48 @@ Two things worth knowing before touching it again:
- **The sweep scope is not pre-selected to the operation you are looking at.** It would be
the obvious convenience and it is a trap: the scope is written **at arm time**, so a page
default would arm a narrower sweep than the operator believes they are looking at.
+
+## Slice 2, as shipped
+
+Six commits, `1da6f22` → `1058b98`, in the order the dependencies allowed rather than the
+order they are numbered here.
+
+**`/operations/<id>` stopped guessing** (`1da6f22`). Four hardcoded id tests decided what an
+operation page rendered — the runner kind, the job kinds, the sweep lane, and (in
+`railStates`) every band's state. All four were right only by coincidence of today's registry.
+A registered `transcode` — external, no runner — would have rendered the BACKFILL SWEEP'S
+CONSOLE under a "Transcode" heading, with a live Start button arming a corpus-scale GPU
+commitment. `ExternalOperation.runner?: AutoQueueKind` now names the runner (`dispatch` cannot:
+download and transcription are `external` WITH a runner, transcode would be `external` with
+none), `sweepLaneIdFor` takes the descriptor and reads `lane.queueKey`, and **null is a real
+answer** rendered as a "no console here" panel. `railStates`' band loop asks the payload's live
+`lane.operations` membership instead.
+
+**One lane resolver** (`5743f08`). `laneForOperation` special-cased digest with a
+`digestLaneFor` call and returned `.lane` for everything else — so the rule lived in two places
+and only digest's copy was live. **Diarization already declared a `laneFor` and the arbiter had
+never called it**: a sortformer/vulkan run reserved as `contendsFor: "cpu"` while holding
+~4.4 GB of the same 8 GB card transcription wants. Now one resolution off the kind's own
+declaration, and digest gains the `laneFor` it should always have had.
+
+**One runner path** (`eefc873`). `common/controller/operationJobs.ts`. There had been four
+copies of "run one operation over one channel" and no two agreed — the editor's backfill
+summary printed `deferred` and `blocked`, the sweep's printed `skipped`, neither printed the
+other's. The runners **do not drain**; the three callers that want sequencing drain at their
+own call site, because a runner that drained would consume the stream the editor renders as a
+live log. `onDone` exists because `common/` cannot import `next/cache`.
+
+**The stage rename** (`947ac32`), with `GROUP_STAGES` riding along. Two assertions that would
+have passed vacuously after the rename were caught and updated (`notEqual` against a value the
+union can no longer hold; a count-0 against a label nothing renders). `?stage=backfill` still
+resolves through `STAGE_ALIASES` — an unknown `?stage=` falls back to the overview, which is
+right for a retired stage and wrong for a renamed one.
+
+**The shared shell** (`1058b98`). `OperationWork.tsx`, presentational only, with the criterion
+written into the file: if it ever branches on an operation id, stop.
+
+**What slice 2 deliberately did not do**, so silence is not read as a decision: register
+`transcode` (it waits for the vocabulary pass); rename the population aria-labels, `StationId`,
+or the `/actionable` section id; touch `snapshot.backfill`, `BACKFILL_QUEUE`, or the widget's
+persisted `SectionId`; or fold `digestBucketAction` into the shared runner (it takes an explicit
+id set the runner has no parameter for).