# The editor: one noun, not a pile
**Status: COMPLETE (verified against the tree 2026-10-09).** Slices 1–8 shipped (records below). Slice 9 shipped
as one-core Phase 1 slice 1.3 (`253826b` → `451c454`, 2026-09-07; record `one-core-phase-1.md`): `digestSweep.ts`,
`backfillSweep.ts`, `arbiter.ts`, `SweepLane.tsx` and `SweepScope.tsx` are gone, the sweep resume hooks left
`instrumentation.ts`, and each lane has one console. This is the UI half of
[`unified-operations-model.md`](unified-operations-model.md), which states the backend half
("four schedulers for what is really one kind of work"; steps 1, 5, 6 open). Read that one
first if you are touching a dispatcher; read this one if you are touching a page.
## The diagnosis, in one line
**The backend has found its organizing noun — the operation — and the UI has not.**
The editor grew page-by-mechanism. Each scheduler, sweep, runner and derived feature landed
with its own route, its own fieldset, its own pause switch and its own counter. What that
adds up to, counted rather than estimated:
- **28 pages** behind a **19-link nav**, whose "Pool" group holds **10 unrelated tools**;
- **four channel tables** (`/channels`, the dashboard's, `/actionable`'s, `/cleanup`'s);
- **four job lists** (`/jobs`, `/jobs/active`, `/jobs/queue`, the per-channel one);
- **three renderings of lane state** (the rail, the dashboard cards, `/jobs/active`'s lanes);
- **eight settings surfaces**; and
- **four different answers to "what needs doing"**.
Digest and attribution are first-class in the **registry** — `common/lib/backfillKinds.ts`
says in its own header that registering attribution "lit the channel stage card,
`/actionable`, the dashboard instrument and the widget strip with ZERO UI changes" — and
second-class in the **UI**: no route, no stage of their own, no per-video view, and two
settings fieldsets for one concept.
The coherent parts of the UI are exactly the parts that already treat the operation as the
noun: `StateBand` drawn at three scales off one fold, and `operationCatalog()` driving both
the `/channels` columns and the auto-queue rail. The pile is everything that does not.
## The vision statement
> The editor is the operator console for turning a large, mostly-verbatim transcript archive
> into a derived corpus and publishing it. Its nouns are the **Corpus** (channels and videos,
> read from disk), the **Operations** that produce derived artifacts from it (download,
> transcribe, digest, diarize, attribute — one registry entry each, with a state per video, a
> cost per unit, a lane it runs on, and a rule in the policy tree that dispatches it), the
> **Sites** that publish slices of the corpus, and the **Machine** that supplies capacity and
> storage. Every screen answers one question about one of those nouns; a number shown in two
> places is the same fold over the same snapshot; a pause is a hold and never a stop; and
> reachable work is never summed with work that is blocked, deferred or gone. A new operation
> is a registry entry, and it appears on the board, the channel page, the video page and the
> policy tree without a new page.
The last sentence is the test. Today registering an operation lights four *counters* and no
*surfaces*. When it lights the surfaces too, this work is done.
## Where "operation as the noun" honestly breaks
A model is worth writing down only with its exceptions attached, or the next person meets one
and concludes the model was wrong.
| Thing | Verdict |
| --- | --- |
| **Sync** | **SHIPPED 2026-08-29** (slice 8a+8b). An operation, but channel-scoped and cadence-triggered: `scope: "video" \| "channel"` and `trigger: "backlog" \| "cadence"` are on `OperationDescriptor`, `SYNC_OPERATION` is first in `operationCatalog()`, and `/operations/sync` is its page. Two corrections to the wording above. **(1) "Keeps its own runner" could not be `runner`** — that field is typed `AutoQueueKind` and the heartbeat is not one of them, so the page chooses the cadence console off `trigger` and `runner` stays `undefined` (`operations.test.ts` pins it, with the reason). **(2) The board row is composed, not folded into a band** — `OperationBand`'s five populations are videos and sync's figures are channels, so `buildOperationBands` gains no sync band and `OperationRail` draws a `SyncRailRow` first in the same `
`. The keep-latest checks and the saved-video backup that ride the same heartbeat (`editor/app/scheduler/runTick.ts`) are **Storage chores**, not sync, and are labelled as such on the console, in the skip reasons and on Saved videos. |
| **Build / deploy** | **Not** operations. Per-site, no per-video state, no lane. They are the Site's *publish* verb → `/sites/[siteId]`. |
| **Cleanup / saved-videos / relocate** | **Not** operations: they consume outputs rather than producing derived artifacts. Third noun, **Storage**, filed under Machine. The channel `cleanup` stage stays where it is. |
| **Transcode** | **REMOVED 2026-08-30 — never fired in production; the band question is closed.** The census that settled it (read-only `jq` over 68 channels, 2026-08-30): `buckets.untranscoded` non-empty in **0 of 68**, `multipleAudioFormats` non-empty in 0 of 68, `cleanupBytes.foreignAudio > 0` in 0 of 68, both `failed-transcodings` files on disk **0 bytes**, and only **4** channels passing the gate `handling === "transcribe" && !!audioFormat`. It could not have mattered either: `transcribeOne`'s `resolveAudioFile` falls back to any real audio file when `audio.` is absent. **Deleted**: the four controllers, the four job kinds, the channel page's Transcode stage and station, `buckets.untranscoded` as a to-do list, the video list's "transcoded" dot and failed filter, the catalog entry and with it `appliesTo`/`operationApplies` (the entry was the only taker) — catalog 8 → 7. **Kept**: `transcodeAudio` and the download path (`extractionMode: "app"`, two callers), the per-file *Transcode → * rows on the video page, and both wrong-format-audio sweeps, re-gated on `!!config.audioFormat`; the bucket is `wrongFormatAudio` now. **`EXTERNAL_BAND_IDS` stays `["download", "transcription"]` permanently** — there is no third band to add. Plan: `plans/editor-transcode-removed.md`; census in FACTS.md, "Verified 2026-08-30 — transcode: what it was, what stayed". |
| **Social channels** | A channel whose operation set is `{fetch-posts}`. An explicit **non-goal** here: leave the short-circuit at `editor/app/channels/[slug]/page.tsx:156` alone. |
| **The channel stage list** | Half-derived and that is correct. The middle (`download … backfill`) derives from `OPERATION_GROUP_ORDER`; the bookends (`configure`, `playlist` / `cleanup`, `diagnostics`, `danger`) are **channel chores**, not operations, and stay hand-listed. Say so in the code so the next reader does not "finish" the derivation. |
| **Digest's two lanes** | A **registry defect**, not a noun problem. `common/controller/arbiter.ts` special-cased `DIGEST_KIND_ID` because digest runs on its own queue. **Resolved in slice 2** by giving digest its own `laneFor(settings)` and resolving every operation through `laneForOperation` — but NOT by "giving every kind a `laneFor` defaulting to `lane`": `laneFor` is optional and its PRESENCE is what `backfillBatch.ts` keys the GPU idle-only rule off, so a default would enrol every operation in that rule (FACTS.md, "laneFor? is OPTIONAL"). Add one only where the lane genuinely varies. |
| **The widget** | A **projection** of the board payload, not a noun. Drop "Monitor" from the primary nav; reach it from the dashboard's pipeline band, next to the thing it mirrors. |
## The nav, end state
Four groups, twelve top-level entries, down from three groups and nineteen — eleven after
slice 5, plus the one recorded exception below:
- **Corpus** — Dashboard, Channels, Review
- **Operations** — Operations *(Schedule folded in, 2026-08-29: it is `/operations/sync`)*
- **Sites** — Sites *(Charts, Search aliases, Deploy, Build and Homepage folded in, 2026-08-30)*
- **Machine** — Jobs *(Active and Queue folded in, 2026-08-30)*, Workers, Cleanup, Storage
*(added 2026-09-17 — see below)*, Saved videos, Settings, Changelog
**One recorded exception, at twelve: `/storage`** (plans/storage-locations.md, agreed with
the operator 2026-09-17). A storage location is a *place the machine keeps bytes* — a Machine
noun like a worker or a job — and not a fold of Cleanup, which is an *operation over* bytes
that already have a place. `editor/app/lib/nav.ts`'s header carries the same sentence, and
`nav.test.ts` asserts twelve so the number can only move when a plan says why.
**The rule, and it is umtool's rule, not a new one.** `umtool/components/AppNav.tsx` states
it after the same disease: "SEVEN visible entries, and the cap is still NINE … The next tool
goes UNDER one of these, not beside them." umtool folded three judging piles into one
`song ▸` group **at their old URLs**, which is the second half of the rule: fold rather than
add, and **every retired route redirects rather than 404s**. Both apps now cite the same
comment, deliberately — two consoles drifting into two nav philosophies is how this started.
## Reconciliations with existing plans
Recorded here because each of these is a plan that says something slightly different, and
silence would read as a contradiction nobody noticed.
- **unified-ops step 5** says the four pause fields become "node state on the tree". The
operator-facing gate is **per operation**, not per node, so the two meet at a definition:
*"pause operation X" = "pause the root of the tree that dispatches X"*. Ship one
`pauseOperationAction(id)` that maps onto the four legacy fields now; step 6 migrates the
storage underneath it. **No new settings block** — that would be a fifth pause.
- **unified-ops step 6** is slice 9 here. Untouched, and last.
- **PLAN.md Phase 11a** says "extend `/actionable`, don't build a parallel page". Slice 4
*dissolves* `/actionable`, so 11a is rewritten rather than contradicted: digest review and
uncertain attribution become the **attention section of `/operations/`**; duplicates,
viewer feedback and channel-context promotion become a small **`/review` under Corpus**.
The `SectionConfig` extension point moves with the sections; it does not disappear.
- **relocate-channel-media.md §6** asks for a Storage panel on the channel page. It goes
**under the existing `cleanup` stage**, not as a new stage id — media location is a storage
chore, and a new stage would be a sixth answer to "what does this channel need". The badge
on the channels table is unchanged.
## The nine slices
Each is shippable on its own, each **deletes something**, and the order is the order the
dependencies allow. Sizes are S/M/L.
1. **The frame — SHIPPED.** `/operations` (the board) + `/operations/` (one operation),
four-group nav, `/auto-queue` → redirect. Deletes the lane ``. UI-only. **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/` 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 — SHIPPED** (`9515083` → `43bb519`; see
"Slice 3, as shipped" below). The Digest, Diarization, Speaker-work-lane and Speaker
attribution fieldsets moved to `/operations/`, each with its own form and action;
`saveSettingsAction` lost the four blocks and the four hidden `*FormPresent` markers with
them. 562 lines out of `SettingsForm`, 171 out of its action. **M.**
4. **`/actionable` dissolves — SHIPPED** (`a623958` → `43c4e26`; see "Slice 4, as shipped"
below). ~~Depends on unified-ops step 1~~ — **SATISFIED 2026-08-26**
(`efb0cf9` → `00b1c8a`): `snapshot.backfill.digest` is now the only digest work list, and it
carries what this slice needs and the deleted `noDigest` bucket did not — the per-state
split (`blocked`, `deferred`, `partial`) and the coverage pair (`eligible`, `present`).
The per-operation sections
become the "channels with reachable work" table on `/operations/` — which is what
`SweepPlan.tsx` already draws; `NeedsWorkPanel.tsx:61-76` drops `noDigest` in favour of
band `reachable` (`buildBands.ts:163`); the non-operation sections go to `/cleanup`, Sites
and the dashboard. **Keep `actionable/lib/loadActionable.ts`** — the widget payload reads
it. e2e: `actionable.spec.ts`, `cleanup-actionable.spec.ts`, `site-scope.spec.ts:101`,
`backfill.spec.ts:769`, `attribution.spec.ts:358`, `navigation.spec.ts:38`. **M–L, medium
risk** — dashboard numbers move, and the changelog must say so.
5. **Sites absorb charts / aliases / deploy / build / homepage — SHIPPED** (`140212a` →
`df9b839`; see "Slice 5, as shipped" below). The bullet's premise — "all per-site facts
already; only their routes say otherwise" — was wrong for two of five: `/build` has no site
notion at all, and `/homepage` edits `sites/_homepage/homepage.json`, which `isValidSiteId`
rejects, so the hub can never be a `[siteId]`. `/deploy` was half and half. What shipped:
the per-site halves are TABS (`/sites/[siteId]/{charts,aliases,publish}`), the family's
halves — Release notes, the batch build with its mode, the Hub and the Pool — are sections
of `/sites`. `?site=` stays for Dashboard and Channels; on a site's own pages the PATH is
the selection and the picker follows it. Deletes five pages. **M.**
6. **The video page: one panel per operation — SHIPPED** (`6986efc` → `d5ca90a`; see
"Slice 6, as shipped" below). `DigestPanel.tsx` is the digest's BODY inside a generic
`OperationPanel`, built per registry entry from `inspectVideoOperations`; attribution and
diarization get a per-video view, and a Run for one video with them. The **"Open in umtool"
link** (`VideoPanel.tsx`, `data-umtool-link`, landed in `818bfc7`) stays above the panels,
unchanged: it is a Corpus→umtool bridge, not an operation. The bullet was wrong in six
places and the section below records each — most consequentially, `hasDigest()` was the
channel COUNT's helper and not the page's, `hasAttribution()` was already dead, and the
page was duplicating the digest operation's own `state()`. **M.**
7. **One pause — SHIPPED** (`c924f73` → `685cba3`; see "Slice 7, as shipped" below).
unified-ops step 5 as reconciled above. The bullet was wrong in four places and the
section below records each: there were EIGHT actions in two files over FOUR gates with
THREE polarities, not six; `PauseDownloadsButton` had zero importers; the control is keyed
by LANE, not by operation (`PauseOperationButton({operation})` would have implied a switch
per operation, and three speaker operations share one gate); and the runner pages, which
the bullet did not mention, got their pause too. Every aria label survived, byte-identical.
**M, medium risk.**
8. **Machine.** `/jobs`, `/jobs/active` and `/jobs/queue` become one page and one LIST;
`WorkersField.tsx` (556 lines) moves to `/workers`; `/scheduler` becomes
`/operations/sync`. umtool's `/` already reads "one activity list over both job
registries" — this is the same shape. **M–L.**
**8a+8b SHIPPED** (`ea04b4e` → `fecac0f`; see "Slice 8a+8b, as shipped" below): sync is
catalogued and `/operations/sync` is its page with the whole `syncScheduler` block on it,
and the worker list is configured on `/workers`.
**8c SHIPPED** (`2226680` → `21feed3`; see "Slice 8c, as shipped" below): `/jobs` is one
list — the live head is polled, the history tail is paged, and a row is one job. The
bullet said "a mode and one table"; a mode over three shapes is three pages with shared
chrome, so there is no mode. **Slice 8 is complete.**
9. **Sweeps retire; the tree dispatches everything.** unified-ops step 6 plus arbiter
persistence. Deletes `digestSweep.ts`, `backfillSweep.ts`, `SweepLane.tsx`,
`SweepScope.tsx`, the sweep checks at `arbiter.ts:205-213` and the resume hooks at
`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 — shipped, 2026-08-26, in three commits rather than one** (a 60-file
identifier sweep is only reviewable when nothing else is in it): `BackfillKind` → `Operation`,
`BackfillLane` → `Lane`, `backfillKinds.ts` → `operations.ts`; then the id spaces slice 2 handed
forward (`StationId "backfill"` → `"speakers"` with a derived label, the `/actionable` section
id, `BackfillStage` → `SpeakersStage` and its population aria-labels) and "unit" disambiguated
(a dispatch unit keeps the word; cost is "cost basis"); then `transcode` registered. The
mapping, the rule that decided each name and the list of what deliberately still says
"backfill" are in `FACTS.md` ("Verified 2026-08-26 — the vocabulary pass").
## Out of scope, stated so silence is not read as a decision
- The **reader-facing export site** — that is PLAN.md Phase 10, a different audience and a
different set of nouns.
- **The sweep retirement itself** is in scope only as slice 9, and it is gated on the sweep
having run in production once.
- **Social channels** keep their short-circuit.
- **The widget's internals.** It is re-parented in the nav and otherwise untouched.
## Slice 1, as shipped
Routes: **`/operations`** (the board — the rail, every row a link, the arbiter bar, a sync
row) and **`/operations/`** (one operation: the runner lanes verbatim for `download` and
`transcription`, the sweep lane for the registry kinds, with the band and the running jobs
for that operation above it). `/auto-queue` **redirects** to `/operations`; the API paths stay
`/api/auto-queue/*` (three specs hit them directly).
Two things worth knowing before touching it again:
- **The lane `` is gone, and its structural contract is not.** `AutoQueueView.tsx`
carried a ~1,200-line e2e contract — a literal `` per
runner kind with an `` named exactly "Auto-transcribe"/"Auto-download", nothing nested
inside it that is itself a ``, native ``/checkbox controls, and
`role="status"` reserved for "Saved." — and every clause of it survives in
`RunnerOperationView.tsx`. What did not survive is the *reason the non-selected lanes
stayed mounted*: with one lane per page there is no switch to lose policy-edit state
across, and the one page-wide option count the suite asserts
(`auto-subs-replace.spec.ts:409`) is satisfied on `/operations/transcription`.
- **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/` 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).
## Slice 3, as shipped
Five commits: `9515083` (the plan) → `f14ceb5` (the seams) → `43bb519` (the move) →
`b303817` (copy, the `/settings` pointer, docs) → `7c34bc5` (one collision the move created).
The plan is [`editor-ia-slice-3.md`](editor-ia-slice-3.md).
**The one thing the move broke, and it is worth knowing why.** Putting a form inside
`section[data-lane]` puts its PROSE in that section's scope too. The lane form's paragraph
still said "…so it lives with the plan it produces", and `The plan` is the heading of the
sweep console now sitting directly above it — `backfill.spec.ts:484` scopes
`getByText("The plan")` to that section, `getByText` matches substrings, and two matches is a
strict-mode violation rather than a near miss. Moving a component moves its sentences into
someone else's selectors; the file now carries a comment saying that phrase is off-limits
there.
**The markers were the whole deletion.** Each of the four blocks in `saveSettingsAction` was
gated on a hidden ` `, and every one existed for a single reason: an
unchecked checkbox is ABSENT from a FormData, so one form saving everything meant a submit
from a form lacking a block would read that block's every switch as off. What that would have
cost is written in the old comments and is not small — a disarmed corpus sweep, a dropped
diarization capture lane (which lets the next Clean-audio sweep delete audio being held),
`allowRedownload` flipped on against a 97%-full disk, ~194,000 model calls armed. One form per
block retires all four, and the `...current` spread each block already did becomes the whole
isolation story. The four keys in `next` now read `getSettings().` with the same
"edited on its own page; preserved here" comment `autoQueue` and `savedVideoBackup` carry.
**Two decisions worth recording, because both could plausibly have gone the other way:**
- **The lane's block is rendered per LANE, not per operation.** `settings.backfill`
(`enabled`, `weight`, `concurrency`, `allowRedownload`) governs `BACKFILL_QUEUE`, which
diarization, `attribution-text` and `attribution-diarized` share — so it is not any one
operation's setting and could not go on any one operation's page as such. It is rendered
inside `SweepOperationView` beside `SweepLane`, which is exactly where the shared pause and
the shared sweep already are: one definition, three renders, on every member page of the
lane. The digest lane has no equivalent form — one member, and its lane facts
(`sweepEnabled`, order and reach) are already in the digest block and the `OrderReach`
control.
- **`settingsBlock` is on the descriptor, not in a table keyed by id.** Same rule slice 2 set
for `runner` and `sweepLaneIdFor`. `settingsFormFor` in `/operations/[id]/page.tsx`
switches over the closed union `"digest" | "diarization" | "attribution"`, so a new block is
a type error rather than a page that quietly renders nothing — and both attribution
operations get one form because they declare one block, not because a table lists them
together.
**A `role="status"` boundary now has to be stated.** `SaveBar`'s comment claimed the only
`role="status"` on "this page"; the truth is RUNNER pages, which is what
`auto-subs-replace.spec.ts:415` (`getByRole("status").first()` on `/operations/transcription`)
depends on. Sweep-fed pages now carry settings forms with their own status regions, they have
no policy tree, and the two never meet. The comment says so.
**Specs.** The three form-driving tests moved out of `settings.spec.ts` into
`operation-settings.spec.ts` and repointed; the "unrelated save" one became the genuinely
cross-form test it always described (digest on its page, admin title on `/settings`). Two
blocks that had **no form-driving spec at all** — attribution and the lane — have one now,
including the one that proves what the markers used to buy: saving the diarization form beside
the lane form leaves `backfill.enabled` alone. 7/7.
**What slice 3 deliberately did not do:** unify the three other file-local `Field` copies
(`ChannelForm`, `SiteForm`, `OverviewPanel` — they differ in props and markup, and the new
`components/forms/Field.tsx` says so at the top); touch `WorkersField` (slice 8), the pause
trio (slice 7) or `/actionable` (slice 4); change any persisted key or field `name`; or retire
a route — nothing redirects, because nothing moved off a URL.
## Slice 4, as shipped
Seven commits: `a623958` (the plan) → `481698a` (the seams) → `a94d5e3` (operation pages +
the dashboard rename) → `68f1079` (`/channels` reports, `/cleanup` tables) → `202fed6`
(`/review`) → `43c4e26` (retire + redirect) → `808e3fc` (this section). One follow-up,
`97ee12a`: the slice's e2e run found `channels-sort.spec.ts` asserting on "Downloads" /
"Transcripts" headers that had matched nothing since the per-operation strip (`4d57a5e`)
relabelled them; it now iterates the fixed columns, "Report" included. The plan is
[`editor-ia-slice-4.md`](editor-ia-slice-4.md).
**The dashboard's digest number is a RENAME, not a re-sourcing, and the slice-4 bullet above
had it wrong.** `actionableNoDigestCount` already returned `digestWorkOf(row.snapshot).reachable`
— the same `reachableOperationWork` that `buildBands.ts:63` folds into the digest band. So
"drop `noDigest` for band `reachable`" was describing a change to a number that was already
that number. What shipped is honest names on the same figure: `actionableDigestReachableCount`,
wire field `digestReachable`, column "Digest to do", badge title "N video(s) the digest lane
can work on now". The changelog says the name changed and the number did not.
**The census was carrying a 6.7 MB parse for one reader.** `summary.duplicates`,
`duplicateOverrides`, `mediaScan` and `mediaScanOverrides` had exactly one consumer — the
review half of `/actionable` — and every dashboard render and every widget `/api/widget/actionable`
poll loaded them. They are `ReviewSummary` / `loadReviewSummary` now, in `review/lib/`, read
only by `/review`.
**The channel-work slot renders outside `section[data-lane]` and outside the runner's
``, and both halves of that are load-bearing.** Inside the runner section it would be
a nested ``, which `RunnerOperationView`'s contract forbids; inside the sweep section
its headings, prose and channel slugs would land inside every `data-lane`-scoped `getByText`
in the suite — the same failure slice 3 paid for with the lane form's "The plan" paragraph.
It also carries no `role="status"`: the runner pages reserve that role, and
`auto-subs-replace.spec.ts:415` takes `getByRole("status").first()` on
`/operations/transcription`.
**Two properties were dropped rather than repointed, both because the page they described is
gone.** `backfill.spec.ts` (10) asserted that the backfill section was "not hidden behind
nothing pending" — that gate was a hand-exhaustive boolean over ten lists, and an operation
page has no such gate for a section to hide behind. And `attribution.spec.ts`'s "3" was
1 diarized + 2 text summed in one `/actionable` row; the per-operation pages split it by
construction, so each page asserts its own band instead. The sum still lives on the channel
page's speakers stage, asserted twenty lines above in the same test.
**`/cleanup` is where the site-scope pool case belongs.** `site-scope.spec.ts` used
`/actionable`'s stale-reports rows to prove a pool view ignores the active site.
`ChannelCleanupCard` is `` and `/cleanup` reads no
`searchParams` at all, so it makes the same point more directly. `SweepPlan` was the wrong
target: it puts a snapshot-less channel into a count, not into a row.
**What slice 4 deliberately did not do:** rename the `actionable*` identifiers,
`ActionableRow`/`ActionableSummary`, or the `/api/widget/actionable` path — that is a
vocabulary pass, reviewable only on its own, and the wire path is a contract a pinned widget
is polling. `editor/app/lib/actionable/` keeps the name as "the actionable census". The two
cleanup tables were not merged into `ChannelCleanupCard`. "Uncertain attribution" is not an
attention section: no bucket or confidence field exists yet, so it would be net-new rather
than a move.
## Slice 6, as shipped
Five commits: `6986efc` (the plan) → `731f9fa` (`common/controller/videoOperations.ts` + its
test, `digestSectionStates`, `hasDigest` moved, `hasAttribution` deleted) → `1eae26e` (`ids`
through the backfill channel job) → `6966239` (the panels) → `d5ca90a` (the per-video Run and
the e2e). The plan is [`editor-ia-slice-6.md`](editor-ia-slice-6.md).
**`hasDigest` was the CHANNEL COUNT's, not the page's, and the bullet above had it backwards.**
Its one caller was `countDataFiles` in `controller/channels.ts` — the batch ground-truth walk
under `listChannelStatsFromDisk`. It is identity-blind on purpose ("is there a digest at all",
never "is it current"), and `channelProjection.test.ts` pins that a record with a non-empty
section counts. So "deletes `hasDigest()`" is a MOVE: it is now the private
`hasDigestWithItems` in `channels.ts`, body and comment verbatim, and the export is gone. What
being exported cost was legibility — it read as a general "is this video digested", which is
the second definition of digested the registry's `state()` exists to be the only one of.
**`hasAttribution` was already dead** (zero callers across `common editor export homepage
umtool mcp`) and is deleted. **`hasDiarization` is NOT dead and stays** — it is the
audio-deletion guard in `cleanAudioFromTranscribed.ts`, plus `diarizeOne.ts` and its test. The
`files.hasDiarization` flag on `VideoFiles` is a different thing (a listing flag) and is
untouched.
**The page was duplicating the digest operation's `state()`.** It called `resolveDigestTarget`
itself with `lane: "local"` and folded `isSectionFresh` plus the `derivedFrom` rule per
section, beside the registry's own fold of exactly the same rule — and the registry's
`resolveTarget` calls the same resolver with NO lane, which resolves to `localAppId`, the same
identity. `digestSectionStates(record, sections, target)` in `lib/digest.ts` is now the one
fold: `digest.state()` counts through it and the panel's per-section rows are its output, so a
video's panel and its channel's work list cannot disagree about one section. No displayed
value moved.
**The external operations get no panel, deliberately.** Download, transcode and transcription
already have a per-video surface — the `PipelineStageCard`s inside `VideoPanel.tsx`, which
carry their own actions. `inspectVideoOperations` is over `OPERATIONS` (the derived-data
registry) and the asymmetry is stated in its header rather than left to be rediscovered.
**`ids` on the channel job is what makes a per-video Run possible.** `runBackfillBatch`
already took `ids` and intersected them with disk; `BackfillChannelJobOptions` did not, and
`countBackfillWork` walked every dir with no filter — so a per-video job would have sized its
progress bar to the whole channel. `ids` now goes through the job to both, and it is in
`spec.params` beside `kindIds`, so a replayed per-video run stays per-video.
`runOperationChannelJob` is unchanged; the dispatcher stays channel-scoped.
**A shown-but-disabled operation says "switched off".** `shownOnVideoPage` is `enabled ||
any output present`, so a sidecar captured before its feature was switched off stays visible —
and without the extra word its pill would read "stale — regenerating would replace this"
beside nothing that can regenerate it. The Run button is not drawn for it, and
`runOperationForVideoAction` refuses on the same condition by name.
**The vision test now holds for the video page.** `OperationPanel`'s body switch has an arm
for an operation with NO settings block, which renders its outputs list — so a fifth registry
entry gets a panel, a state word and its outputs with nobody editing a component. The state
words are one table (`components/operationState.ts`); `digest.spec.ts`'s pill contract
("not generated", "current", and a stale string containing "stale") survives verbatim, and
`partial` deliberately contains "stale" so a config change that leaves a video part-done still
satisfies it.
## Slice 7, as shipped
Five commits: `c924f73` (the plan) → `fdef74c` (`common/lib/pauseGates.ts` + its test, and the
three dispatch holds on it) → `fbc2d36` (one action pair; eight actions deleted; the wire says
`held`) → `841372a` (`pauseControl.tsx`, every lane surface on it, two button files deleted) →
`685cba3` (the runner pages). The plan is [`editor-ia-slice-7.md`](editor-ia-slice-7.md).
**Keyed by LANE, not by operation, and that is the decision the bullet above got wrong.** The
gate is per lane: diarization, attribution-diarized and attribution-text all ride
`BACKFILL_QUEUE`, so they share one pause, and a `PauseOperationButton({operation})` would
have promised a switch that does not exist. `pauseLaneFor(operationId)` is how an operation
page finds the lane it is really holding — and it asks the descriptor's `runner` BEFORE its
queue key, because `transcode` shares `TRANSCRIPTION_QUEUE` and has no runner, so a queue-key
map alone would have handed it the transcription pause.
**Four gates, three polarities, eight actions in two files** — not "the six actions".
`transcriptionsPaused` (plus the LIVE `WorkerPool.pauseAll/resumeAll`), `downloadsPaused`,
`digest.digestsPaused`, and `backfill.enabled`, which is INVERTED. The actions were three
pairs in `jobs/actions.ts` and `pauseAllWorkersAction`/`resumeAllWorkersAction` in
`workers/actions.ts`. All eight are gone; `pauseLaneAction(lane)` / `resumeLaneAction(lane)`
in `operations/actions.ts` replace them, and `isGateHeld` / `withGateHeld` in
`common/lib/pauseGates.ts` are the only place the polarity is known.
**`PauseDownloadsButton.tsx` was dead** — zero importers, and its only reference was a line in
a 2026 changelog entry. `PauseTranscriptionsButton.tsx` had two. Both are deleted; the four
inline `LaneControl` descriptors in `LaneDeck.tsx` and the sweep panels' own button are one
`pauseLaneControl` / `PauseLaneButton` in `components/lanes/pauseControl.tsx`.
**Every existing label survived one canonical table, and the reason is a Playwright detail
worth writing down.** `getByRole(…, { name })` matches a case-insensitive SUBSTRING unless
`exact` is passed, and no pause/resume lookup in the suite passes it. So the canonical
`"pause digests"` is found by `auto-queue.spec.ts`'s `"Pause Digest"`, and the sweep panels'
composed `` `${held ? "Resume" : "Pause"} ${lane.label}` `` could be replaced by the
dashboard's names without touching a spec. `"Pause backfill"` / `"resume backfill"` are kept
byte-identical because `backfill.spec` and `widget.spec` select on them directly.
**Transcription's held is the LIVE pool on every UI surface, never the flag.**
`isGateHeld(settings, "transcription")` answers "will the pool be paused after a restart", and
after this slice the flag is read in exactly two places: `editor/instrumentation.ts`'s boot
hook and the action's own "did this change anything" check. Everything else — the dashboard
deck, `/workers`, `/jobs/active`, the widget, the queue view, `/api/pulse`,
`/api/worker/health`, and now the runner status payload's `held` — reads
`getWorkerPool().isPaused()`. The e2e harness rewrites `test-settings.json` wholesale between
tests while the pool keeps its `pausedSnapshot`, so a surface reading the flag would disagree
with the machine it describes.
**The runner pages got their pause, and an honest idle reason with it.** `Start`, `Drain` and
`Stop` act on the RUNNER; the gate holds the LANE, which outlives it. `railStates.ts` stops
hard-coding `gateHeld: false`, so a runner lane can read *Holding* on the rail for the first
time. And `pauseAll()` disables every worker, so `autoRunner`'s `limit()` reported
`no-workers` while paused and `/operations/transcription` said "no enabled worker to run it"
beside its own *Resume Transcriptions* button; there is a `workers-paused` idle reason now,
asked before the no-workers branch.
**One behaviour changed on purpose, beyond the consolidation: a resume is always reachable.**
`disabled` reaches the pause side only. `SweepLane.tsx` disabled both sides on
`!lane.available`, which meant a held lane on a switched-off feature could not be released
from its own page.
**What stayed.** The Speaker lane's *Run the backfill lane* checkbox is still a second writer
of `backfill.enabled` — one field, two places to set it, and they cannot drift. The workers
payload keeps its `paused` / `downloadsPaused` field names (the poll re-reads them, and
`dashboard.spec.ts` documents that), the `/api/widget/sync` path is unchanged, and no settings
key moved: unified-ops step 6 migrates storage behind `isGateHeld`/`withGateHeld` later. The
three copies of the lane NOTE strings (`buildActiveJobs.ts`, `railStates.ts`, `SweepLane.tsx`)
are still three — that is slice 8. *(Done 2026-08-29: they were two copies and a paraphrase,
and they are one `sweepLaneNote` now. See below.)*
## Slice 8a+8b, as shipped
Six commits after the plan ([`editor-ia-slice-8ab.md`](editor-ia-slice-8ab.md), `b8c485b`):
`ea04b4e` (the catalog entry, `scope`/`trigger`, group `sync`) → `23d2cd8` (the rail row, the
page, the redirect, the Storage-chore labels) → `f8e0185` (the whole `syncScheduler` block on
`/operations/sync`, one writer) → `1cdb823` (`WorkersField` and the persisted list on
`/workers`) → `fecac0f` (`sweepLaneNote`) → this docs commit. **The `/jobs` fold — the third
third of slice 8 — is deliberately not here and is its own plan.**
**Nine places the slice-8 bullet could not be implemented literally, and what happened
instead.** The census is in FACTS.md ("Verified 2026-08-29 — editor IA slice 8a+8b seams");
these are the decisions.
1. **"Keeps its own runner" cannot be `runner`.** `OperationDescriptor.runner` is typed
`AutoQueueKind` (`"transcription" | "download"`), `operations.test.ts` pins it `undefined`
for every other id, and `operations/[id]/page.tsx` feeds it straight into
`RunnerOperationView`. The sync heartbeat is a runner in this document's sense and not one
of those. **`trigger: "cadence"` is the switch instead:** the page builds the console as a
server slot when `op.trigger === "cadence"` and `OperationDetail` renders it where it would
otherwise draw `NoConsoleView` — which stays the fallthrough for a registered operation
nothing drives (transcode).
2. **Sync has no band, so the row is composed rather than folded.** `OperationRail` drew one
`RailRow` per `OperationBand`, and a band's five populations are VIDEOS. Sync's figures are
channels. Giving it a band would have printed "coverage unknown" over a hollow outline for
a thing that has no per-video coverage; `buildOperationBands` therefore gains nothing.
`OperationRail` takes `sync?: SyncRowView` and renders a `SyncRailRow`
`` first in the same `` — same
link/dot/state-word anatomy, its own figures, and where every other row draws its band it
says *per channel, on a cadence*. Still SSR, not polled: a cadence measured in minutes does
not need a 3-second poll. `OperationsBoard`'s `SyncRow` is deleted.
3. **`group` is required and exhaustive in three places, so sync got a fifth group.**
`groupLabel`, `groupActionLabel` and `GROUP_STAGES: Record` all switch
or key on it. Sync is not `media` — that is download + transcode's `/channels` column
group. `"sync"` is **not** in `OPERATION_GROUP_ORDER` (the column order and the transit
line's station order), and `GROUP_STAGES.sync = []` because sync's channel surface is the
hand-listed `playlist` bookend — a chore, not a stage this Record owns.
4. **"Three copies of the lane NOTE strings" were two copies and a paraphrase.**
`railStates.ts` and `buildActiveJobs.ts` carried byte-identical tables; `SweepLane.tsx` says
the same two facts in a sentence with room to say what to do about it. `sweepLaneNote()`
lives beside `deriveLaneState` and takes ITS input rather than its output, so the word and
the note follow one precedence and cannot disagree. `buildActiveJobs`'s restatement of that
precedence goes with the table. The sweep panel keeps its prose and says in a comment that
it is the long form of the same row. Nothing pinned any of the four strings before;
`laneState.test.ts` does now.
5. **"Wherever the Storage chores show" is smaller than it sounds.** `keptChecksQueued` and
`savedVideoBackupQueued` reach no UI — `SchedulerRun` records neither, and recording them
would be a state-shape change. They show as the `skipped` reasons, the keep-latest interval
field, and the Saved-videos sentence. All are labelled; the Run-now message names them off
the POST body it already carried; a paragraph under *Recent ticks* says which two they are.
**No tick logic changed.**
6. **The catalog is walked by a test that pins its length.** `pauseGates.test.ts` asserts
`ids.length` and an expected lane for every id. Sync is `null` there: the scheduler's own
`enabled` is its switch, and its queue key is neither sweep's.
7. **A worker tag would have grown a `sync` entry.** The vocabulary was
`operationCatalog().map(o => o.id)`, and a tag names a per-video operation a delegate can
take a UNIT of. The builder moved to `workers/page.tsx` and filters `scope === "video"` —
the first consumer to use the field for the reason it exists.
8. **`editor/app/scheduler/` is the RUNNER, not the page.** Only `page.tsx` is deleted (the
`/actionable` precedent). `runTick.ts`, `heartbeat.ts`, `status.ts`, `auth.ts`, `actions.ts`
and `intervalPresets.ts` stay: `instrumentation.ts`, both `/api/scheduler/*` routes,
`syncRow.ts`, `ChannelForm.tsx` and the moved forms import them. A route directory with no
`page.tsx` is not routable, and a 307 answers `/scheduler`. The four view components and
`cadence.ts` moved under `operations/components/sync/` by `git mv`, so the diff reads as a
rename; `SchedulerView` is `SyncConsole`.
9. **Two writers of one settings block became one — and the operator moved the WHOLE block.**
`SettingsForm`'s 14-field *Sync scheduler* fieldset and `SchedulerSettingsForm`'s three
overlapping fields both wrote `syncScheduler`, each relying on the other to preserve what
its own render did not show. The descriptor declares `settingsBlock: "syncScheduler"`, the
operation page's exhaustive switch draws the form below the console, and
`saveSchedulerSettingsAction` is the one writer (`hourOrNull` came with the quiet-hours
pair, where blank is a VALUE). `/settings` preserves the block the way it preserves
`digest`, `diarization`, `backfill` and `attribution`. **The keep-latest interval moved with
it** — it is a field OF this block even though it is a Storage chore's knob, and its hint
now says so.
**Workers: the list is configured beside the workers.** `WorkersField.tsx` moved to
`workers/components/`, `WorkersConfigForm` wraps it with its own *Save workers*, and
`saveWorkersAction` is the one writer of `settings.workers` — read the file, replace that
block, then `reconfigure(workers, { applyEnabled: true })` on the live pool exactly as the
whole-object form did. Nothing else on `/workers` is a form, which is the structural property
slice 3 bought for the operation blocks. Every worker aria-label is byte-identical; three
tests moved from `settings.spec.ts` to `workers.spec.ts`.
**What stayed.** The `scheduler/` runner directory (see 8). The widget's *Auto-sync scheduler*
strip and `/api/widget/sync`. `SweepLane`'s prose (see 4). `SchedulerRun`'s shape,
`syncSchedulerState`, `runSchedulerTick`'s logic and every settings key. `Field.tsx` /
`CardField`. No `console` field on the descriptor — one cadence operation exists, and a second
would name its console rather than have `page.tsx` branch on an id.
## Slice 8c, as shipped
Four commits after the plan ([`editor-ia-slice-8c.md`](editor-ia-slice-8c.md), `9622a5f`):
`2226680` (one row type, one builder) → `ec3279d` (one list: the polled head, the paged tail)
→ `21feed3` (the scheduler's view on the live payload, and its heal) → this docs commit. This
is the last third of slice 8, and slice 8 is now complete.
**Three operator decisions, taken before the plan was written** (STATE.md #12 asked the
design question first — *what is a ROW* — and the answer is the whole design):
- **A row is one job**, whichever of three places knows about it: a registry `JobRecord`, a
`.log` + sidecar the registry has forgotten, or a scheduler slot whose record was evicted (a
phantom). One view type, one server builder, one adapter per source. Merged VIEWS, never
merged registries — umtool's `lib/activity.ts` is the precedent.
- **The page is one list**, not a mode: live head, history tail, one ``, one `` per
job, merged by id with the live row winning.
- **The scheduler reconciliation and its auto-heal fold into the live payload**, so every
consumer of it — `/jobs`, the dashboard, the widget, `/api/jobs/active` — heals drift when
it observes it. `/api/pulse` does not build it and must not: it observes, it never
constructs.
**Thirteen places the slice-8 bullet could not be implemented literally, and what happened
instead.** The census is in FACTS.md ("Verified 2026-08-30 — editor IA slice 8c seams");
these are the decisions.
1. **Three shapes with almost no overlap.** `JobListEntry` (`logSize`, `inRegistry`,
`replayable`, `status: JobStatus | "archived"`), the card renderer's row (`progress`,
`tasks`, `draining`, `drainable`, `canMoveUp/Down`) and the queue's slot view (`ageMs`,
`stuck`, `pid`, `lastLogLine`, `status` including `"evicted"`). **One `JobRowView` that is
the union**: every field name the card renderer had is kept, `status` is widened to
`JobStatus | "archived" | "evicted"`, and the history and slot fields are optional. No
reader narrows on `status`, so the widening compiles.
2. **The type dependency was backwards.** The server builder imported its row type from a
`"use client"` component. `editor/app/jobs/jobRowView.ts` is types-only now, and
`registry.ts`'s "one re-spelled copy" comment names it.
3. **No page read all three sources.** Head = scheduler ∪ registry (live); tail = registry ∪
logs (history); merged BY ID, live wins — a running job is in both and must be one ` `.
4. **The heal already ran on more surfaces than the queue page** (`/jobs/active` built the
queue view for a badge count). Folding it into the live payload widens the set to every
surface that draws work, which is the decision above.
5. **The card renderer groups by channel; a table has no sections.** Eleven spec locators were
`section[aria-label='Active jobs for ']`; they are row filters by slug now —
the locator every existing `/jobs` spec already used. `payload.channels` had no other
reader and is gone.
6. **The last-log-line tail was read for EVERY slot, every poll** (8 KB each). It moved into
the builder and is read only for a row with a `stuck` fact.
7. **The `"running"` nav badge had five consumers and one spec.** The nav key, the layout seed
and the `metric` prop are gone; **`PulsePayload.runningJobs` stays** — `busy` reads it, and
deleting it would be a wire change no page needs.
8. **"Queued in scheduler order" is a change.** `/jobs/active` ordered queued jobs by the
registry's `queuedAt`. They sort by (queue name, position) off one `scheduler.queues()`
snapshot now — that is what *2nd in line* means. `jobs-reorder.spec.ts` still asserts the
EFFECT (a promoted job runs next), which is why it did not have to change shape.
9. **`nth(1)` means "the newest job" in nine specs.** Each was checked one by one; all hold,
because in every case the job under test is running (head, first) or finished seconds ago
(`recent`, first).
10. **`/jobs`'s empty state counts the registry, not the scheduler.** The lane strip and the
health line render ALWAYS — a lane is not a job, so an empty work list is not an empty
page. "No jobs have run yet." renders when the merged rows are empty AND the total is 0;
"No active queues." is gone, because the health line's `0` beside *active queues* says it.
11. **"Reap stuck" cannot be a header action.** Its visibility is `summary.stuck > 0`, a live
fact from the poll and not from the SSR render, so it rides the health line where the old
strip had it. The four static actions (Retry all failed, Clear logs, Pause Transcriptions,
Drain all) are the header.
12. **`reapStuckJobsAction` read the queue view.** It calls `stuckJobIds()` off the live
payload now. `forceRelease` on an already-healed id is safe, and it stamps `endedAt`, so a
force-released job is a `recent` row for half a minute — which is what `queue.spec.ts`
reads after the click.
13. **The two polls disagree about freshness.** A render served from the router cache
(`staleTimes.dynamic: 15`) can be older than the client's last poll, so the payload carries
`builtAt` and the client renders whichever snapshot is newer. A job that just finished
never vanishes and never regresses.
**What stayed.** `buildActiveJobs.ts` in `jobs/active/` (a route directory with no `page.tsx`
is not routable — the `scheduler/` precedent). `RunningJobsList` as the renderer for a page's
OWN jobs, now fed by `liveJobRows` so those cards carry the progress bars they used to drop.
The widget's own `JobRow`. `PulsePayload.runningJobs`. `/api/jobs/active`. `STUCK_AGE_MS` as a
constant, not a setting. The reorder spec's effect assertion. The `gap-0.5` climb from a task
bar to its elapsed timer — the bars are a shared component, and the table's Status stack is
`gap-1` so the nearest `gap-0.5` ancestor is still the task's own wrapper.
## Slice 5, as shipped
Four commits after the plan ([`editor-ia-slice-5.md`](editor-ia-slice-5.md), `5660fae`):
`140212a` (a site has tabs; Charts and Search aliases are two of them) → `ffd5ac6` (Publish is
the site's tab; the family page cuts the release and batches the builds) → `a756049` (the pool
runs from the family page) → `df9b839` (the hub is the family's, and the Sites group is one
entry) → this docs commit. This is the LAST UI-only slice and the one that reached the nav's
end state: **eleven top-level entries, four groups**, with `nav.test.ts` holding it there.
(Twelve since 2026-09-17: `/storage`, the one recorded exception — see "The nav, end state"
above. `nav.test.ts` still holds the number, which is the point of asserting it.)
**Three operator decisions, taken before the plan was written:**
- **(A) The globals live on `/sites`, the family page** — not on a site, and not on a new
static sibling of `[siteId]` (which would shadow a site with that id). `/sites` is the list
PLUS Release notes, Build all sites with the Basic/Docker mode, the Hub and the Pool. It was
needed because two of the five pages have no site at all and a third is half the family's:
a literal "fold them into `[siteId]`" had nowhere to put them.
- **(B) The Search aliases tab renders BOTH sections** — h2 *Global* and h2 *This site — *,
exactly as `EditorAliasesClient` already drew them. Needed because aliases are a global
dictionary with a per-site overlay (a per-site id SHADOWS the global one at compose time), so
a tab showing only the overlay would hide the thing it overlays.
- **The nav end state is one Sites entry, eleven total**, and the five routes 307
(`permanent: false`, the "TEMPORARY" rule in `next.config.ts`). Needed because the redirects
are the second half of umtool's fold rule, and a permanent redirect on a self-hosted admin
surface is a support call with no remedy.
**Sixteen places the slice-5 bullet could not be implemented literally, and what happened
instead.** The census is in FACTS.md ("Verified 2026-08-30 — editor IA slice 5 seams").
1. **"They are all per-site facts already" is wrong for two of five.** `/build` had no
`searchParams`, no `listSites`, eleven corpus-wide actions; `/homepage` edits
`sites/_homepage/`. `/deploy` was half and half. → decision (A).
2. **Aliases are a dictionary with an overlay.** → decision (B).
3. **The picker and the path disagreed on `/sites//…`.** `SiteScopeSelect`'s value was
`resolveActiveSite(searchParams.site)`, so on a site's page it showed whatever was stored
last. → a pure `siteIdFromPathname()` in `lib/activeSite.ts` (six node:test cases): on a
site path the picker's value IS the path segment, the reconcile effect writes it to
localStorage so Dashboard and Channels follow, `onChange` pushes `/sites//`
and "All sites" pushes `/sites`. `seedsSiteParam` and its reason stay untouched.
4. **`?site=` is routed at config level and the query SURVIVES the redirect.** A `has` rule
whose value is `(?[a-z0-9][a-z0-9-]*)` — Next anchors it, so `?site=__all__` misses
and falls to the bare rule — with `:site` in the destination. The matched query is merged
into the destination, so `/charts?site=a` lands on `/sites/a/charts?site=a`: harmless (the
tab reads `params.siteId`), but every spec regex on a captured redirect is `(\?|$)`.
5. **Layout mechanics.** Layout `params` is a Promise; layouts do not rerender and cannot read
the pathname, so the active tab is a client component (`SiteTabs`, the `ChannelTabs`
precedent) and `useSelectedLayoutSegment()` is `null` on the index page = the Settings tab.
A layout's `title.template` applies to CHILD segments only, so the layout exports
`{ default: " — Sites", template: "%s — — Sites" }`, the index page exports no
title, and each tab exports a bare one.
6. **`revalidatePath` literal vs `"layout"`.** `saveSiteAction`'s `revalidatePath("/sites/")`
does not cover the tabs by the letter — it worked only through the "temporary" behaviour
that refreshes every visited page. It is `revalidatePath("/sites/[siteId]", "layout")` now;
the stats build's `/charts` became `revalidatePath("/sites/[siteId]/charts", "page")`, and
the cut-release, build-mode and homepage actions revalidate `/sites`.
7. **The all-sites disabled state cannot exist on a site tab.** `BuildDeployButton`,
`BuildExportButton` and `DeployButton` take `siteId: string` / `siteTitle: string`; the
three "Select a specific site from the sidebar…" notices and both `disabled={!siteId}` are
gone. The missing-Cloudflare-project branch is untouched — that is what the button says when
it cannot deploy, and "the site's page" is now literally the Settings tab beside it.
8. **The Pool is eight job consoles.** `RunningJobsList` renders ABOVE the disclosure (a
running pool job must be visible without a click); the consoles sit under
`Pool jobs`. `getByRole` ignores what a closed disclosure hides, so one
`buildIndex(page)` helper in `e2e/helpers.ts` opens it, presses and asserts "Done" —
replacing three inline six-line copies across nine call sites.
9. **`build/` and `deploy/` were already one module in two directories.** Server modules are
`editor/app/sites/lib/` (seven `"use server"` files plus `buildDeployCore.ts`, which is
deliberately NOT one); components are FLAT in `editor/app/sites/components/`, because the
repo's component dirs are flat and every moved file then has the same import shape.
`SiteTabs.tsx` is under `sites/[siteId]/components/`. Neither `sites/lib` nor
`sites/components` has a `page.tsx`, so neither is routable and neither can shadow a site id.
10. **The hub's form is not what deploys the hub** — the `homepage` package's own deploy script
hardcodes `--project-name archilyzer`. Pre-existing; the section keeps today's copy.
11. **Charts preview data is whatever site `compose-site` ran last.** One sentence says so in
the Charts tab's intro. Out of scope to fix.
12. **`navigation.spec.ts`'s redirect test had no site**, so a captured redirect would 404. It
writes `testsite` before the capture case.
13. **`/sites` became async and reads six things** — all file/registry reads the two retired
pages already did per request; it was `force-dynamic` already.
14. **`BUILD_KINDS` is wrong in both directions** — six kinds, missing the three live-chat
kinds the buttons enqueue and including `build-export`, which none does. Moved verbatim
with a comment saying exactly that; **not fixed** — fixed 2026-08-30, first commit of the
transcode removal.
15. **`SettingsForm`'s "The mode can also be toggled on the Deploy page"** points at Sites.
16. **A stale `?site=` bookmark 404s at `/sites//charts`** exactly as `/sites/`
does, where `/charts?site=bogus` used to fall back to the lone/all site. The picker never
seeds an invalid id, so only a hand-typed URL reaches this. Accepted; in the CHANGELOG.
**What stayed.** `seedsSiteParam` and its reason (seeding on `/sites*` clobbered an in-flight
push to `/sites/`). `BUILD_KINDS`' mismatch (14). `EditorAliasesClient`'s
`siteId: string | null` and its now-unreachable global-only branch — a `git mv` plus one import
line is the whole diff, and narrowing it was not this slice. Both — fixed 2026-08-30, first
commit of the transcode removal. The charts preview's
last-composed data (11). The hub deploy script's hardcoded project name (10).
`RunningJobsList` as the Pool's renderer. `/api/*` — nothing there belonged to any of the five,
and the charts preview's `/stats` rewrite is absolute and unaffected. `deleteSiteAction` still
`rm -rf`s the whole site dir including `chart-templates.json` and `search-aliases.json`; the
tabs only make that visible. No `loading.tsx` anywhere under `sites/` — the layout's
`notFound()` is the guard, and each tab page guards through the same `cache()`d read.