commit 635a3dc13fdf02f5ba3eb1a10de4eb2cd0089bcd
parent 1e0c7914d32935bf67c69a67121b711f554d0ee5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 12:59:18 -0400
plans: slice 7 shipped, and the docs say so
CHANGELOG [Unreleased] leads with what an operator sees: one pause on
every surface that draws a lane and now on the runner pages too, beside
Start/Drain/Stop; a held runner shows as Holding on the operations rail;
/operations/transcription stops saying "no enabled worker" while it is
the pause that stopped it; Resume is always clickable. Every button name
is unchanged and nothing on disk moved — the same four fields, and the
Speaker-lane checkbox and the pause button still write the same one. The
widget's own feed renames digest.paused / backfill.enabled to held, and a
pinned tab reads that lane as not held until it is reloaded.
editor-operations-ia.md: slice 7's bullet is SHIPPED with its hashes and
says where it was wrong — eight actions over four gates with three
polarities, not six; PauseDownloadsButton was dead; keyed by lane, not by
operation; and the runner pages, which the bullet did not mention. A
"Slice 7, as shipped" section records the seven things a later reader
would otherwise re-derive, the Playwright substring rule among them.
unified-operations-model.md: step 5 is HALF done, stated honestly. One
definition and one writer among the controls shipped; the four settings
fields are still four settings fields, and step 6 moves the storage
behind isGateHeld/withGateHeld — which is now cheap.
STATE.md: "Last updated" leads with the slice; the "backfill*Action names
stay" decision notes that two of the names it listed are gone and the
aria-labels are what mattered; "Recommended next" gains the DONE line.
FACTS.md gains "Verified 2026-08-28 — editor IA slice 7 seams": the
census with its anchors and an "As shipped" half, including the one
reader the census missed (MonitorWidget's backfill strip). The vocabulary
table's row for the deleted pause actions is corrected.
Memory: new ia-slice-7-shipped.md with its MEMORY.md pointer, and
ia-slice-4-shipped amended where it still called slice 7 a candidate.
Nothing under transcripts/ was read or written for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
5 files changed, 190 insertions(+), 14 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **One pause, drawn wherever a lane is — and the runner pages have one now too.** Four lanes could be held, and each of them had grown its own button: three components (one of which nothing imported), four inline descriptors on the dashboard, and a fifth on the sweep panels with the opposite emphasis and an aria-label of its own. They are one control now, over one pair of server actions, keyed by LANE rather than by operation — three speaker operations share one backfill queue and therefore one pause, so an operation page is not a per-operation switch. **Every button name is unchanged.** New: `/operations/transcription` and `/operations/download` have their pause beside **Start**, **Drain** and **Stop** — those three act on the runner, the pause holds the lane, and a held lane outlives any runner. A held runner lane now shows as *Holding* on the operations rail, which it could never do before. The transcription page also stops contradicting itself: pausing disables every worker, so it used to say "no enabled worker to run it" directly beside its own *Resume Transcriptions* button, and now says "transcriptions are paused". **Resume is always clickable** — on a lane with no workers, or one switched off, the pause is disabled and the resume is not, because a paused pool with zero workers has to be releasable. The operation page's hold button also adopts the dashboard's emphasis: filled while the lane is HELD, where it used to tint itself while the lane was running fine. **Nothing on disk changes** — the same four settings fields, and the Speaker lane's *Run the backfill lane* checkbox and the pause button still write the same one, on purpose. The monitor widget's own data feed renames `digest.paused` and `backfill.enabled` to `held`; a pinned widget tab shows that lane as not held until it is reloaded.
- **"What needs doing" is answered on the page that does it, and `/actionable` is gone.** One page listed ten kinds of pending work, and it was the fourth place in the editor that answered the same question — so a channel with undownloaded videos appeared on the dashboard, on `/channels`, on `/operations` and there, four times, in four vocabularies. Each of its sections now sits with the work: **Download** and **Transcription** each carry the channels with that operation's backlog and its attention items (videos lost before they were ever fetched, truncated downloads, truncated transcripts — the ones a runner cannot simply retry), **Digest** carries the passes that recorded warnings, `/cleanup` carries the two reclaimable-audio tables, and `/channels` carries how fresh each channel's report is, with *Refresh report* on the row and *Update all reports* in the header — the report is the caveat on every count in that row, so it belongs beside them. Duplicate clusters and media-integrity findings get a new **Review** page under *Corpus*: both are flag-only, both a human decides, and neither is an operation or a pile of bytes. `/actionable` redirects to `/operations`, so every bookmark still lands. **Nothing on disk changes** — no report field, no setting, no widget section. The dashboard's *No digest* badge and column are now **Digest to do**: it is the same number under its right name, the figure the digest lane could act on today, which is what it has always been. The monitor widget's own data feed renames its `noDigest` field to `digestReachable` to match; a pinned widget keeps working and shows the same figure.
- **Re-acquired audio is handed to auto-transcribe instead of being deleted under it.** On a channel that only downloads subtitles, the audio the speaker lane fetches sits next to YouTube's own auto-captions — which is precisely the shape the *replace auto-captions* transcription runner looks for, and the channel's report is rebuilt about a second after any work on it finishes. Nothing coordinates the two, so the runner could start on a file the speaker lane was about to delete: the transcription then fails, and the video is written to the channel's permanently-honoured failed list. The lane now checks, at the moment it would delete, whether the transcription policy would take this video — and if it would, leaves the audio for it, saying so per video and in the run's summary line. That audio then behaves like any other download: it stays until you run *Clean audio from transcribed*. Audio is still deleted immediately in every other case, still kept for a video marked *do not clean*, and the hand-off is refused when free disk is below the mark the download runner itself would need — the lane never keeps a file the runner would have refused to fetch.
- **A video whose audio disappears mid-transcription is skipped, not marked failed.** Losing the input file is a media problem that fixes itself on the next attempt; recording it as a failed transcription blacklisted the video for the channel's manual whisper run for good. Both the local engine and a delegated remote one now report it as "no audio", which the queue already treats as "try again later".
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -2209,8 +2209,9 @@ and is the only thing `common/lib/operations.ts` still calls "backfill".
(persisted — `jobReplayRegistry` reads `p.kindIds`); the lane runners `backfillSweep.ts`,
`backfillBatch.ts`, `backfillReacquire.ts`, `backfillAvailability.ts`, `diarize-backfill.ts`;
`backfillActions.ts` / `backfillChannelAction` / `runBackfillChannelJob` (run the LANE over
-one channel); `pause|resumeBackfillAction`, `start|stopBackfillSweepAction`, the
-`/api/test/resume-backfill-sweep` route; `SweepLaneId "backfill"` and `data-lane="backfill"`;
+one channel); `start|stopBackfillSweepAction`, the
+`/api/test/resume-backfill-sweep` route (`pause|resumeBackfillAction` were listed here too —
+**deleted by slice 7**, which replaced all eight pause actions with one lane-keyed pair); `SweepLaneId "backfill"` and `data-lane="backfill"`;
the aria-labels `pause backfill` / `resume backfill` / `start|stop backfill sweep` (they act on
the lane); the widget's `SectionId "backfill"` (serialized as `?backfill=1` / layout code `bf`
— permanent); `SweepKindCounts` and every other `*Kind*` outside the registry's export table
@@ -2669,3 +2670,75 @@ the site-scope pool assertion could not move to a sweep plan.
already documented).
- `editor/app/lib/actionable/` keeps its name deliberately: renaming the `actionable*`
identifiers is a vocabulary pass, and `/api/widget/actionable` is a wire contract.
+
+## Verified 2026-08-28 — editor IA slice 7 seams (one pause)
+
+Census taken before writing `editor-ia-slice-7.md`, against `494cbee`. Read-only observation
+at census time; the "As shipped" notes were added with the docs commit.
+
+**Four gates, three polarities, eight actions in two files** — the IA bullet's "six actions"
+was wrong. `transcriptionsPaused` (`settings.ts:152`, plus the LIVE
+`WorkerPool.pauseAll/resumeAll/isPaused`, `workerPool.ts:283-316`, re-applied at boot by
+`editor/instrumentation.ts:56-73`); `downloadsPaused` (`:157`); `digest.digestsPaused`
+(`:474`); `backfill.enabled` (`:289-292`, **inverted**, shared by diarization /
+attribution-text / attribution-diarized, and deliberately also written by the
+`LaneSettingsForm` checkbox, `operations/settingsActions.ts:214`). The actions were
+`jobs/actions.ts:150-178` (downloads), `:180-215` (digests), `:329-379` (backfill) and
+`workers/actions.ts:9-23,60-83`.
+
+**Three UI copies, one of them dead.** `PauseTranscriptionsButton.tsx` had two importers
+(`jobs/active/page.tsx:37`, `WorkersView.tsx:125-129`); `PauseDownloadsButton.tsx` had
+**zero**; `LaneDeck.tsx` carried four inline `LaneControl` descriptors
+(`:126-148,216-238,283-309,437-459`); and `SweepLane.tsx:187-211` was a fifth, on the ui
+`Button`, with a COMPOSED aria-label and the OPPOSITE emphasis (warning tint when NOT held).
+
+**The inversion lived in three readers and the widget shipped it raw.** `LaneDeck.tsx:471`
+(`gateHeld: !laneEnabled`), `operations/lanes.ts:334-336`, `buildActiveJobs.ts:337`;
+`api/widget/sync/route.ts:72,96,289,296` sent `digest.paused` and `backfill.enabled`.
+
+**Playwright's `getByRole(…, { name })` is a case-insensitive SUBSTRING match unless `exact`
+is passed, and no pause/resume lookup in the suite passes it** (grep-verified). That is the
+whole reason every label survived one canonical table: `auto-queue.spec.ts:1186`'s
+`"Pause Digest"` (scoped to `section[data-lane="digest"]`) matches `"pause digests"`.
+`"Pause Speakers"`, `"Pause Derived"`, `"Resume Digest"` and `"Hold the lane"` are asserted
+nowhere.
+
+**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".
+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.
+
+**The runner pages had no pause at all** (`LaneHeader.tsx:51-84`: Start/Drain/Stop only;
+`AutoQueueKindStatus` carried no held field; `railStates.ts:36` hard-coded `gateHeld: false`).
+Worse, `/operations/transcription` said **"no enabled worker to run it"** while paused, because
+`pauseAll` disables every worker and `autoRunner.ts:598` reported `no-workers`.
+
+**As shipped** (`c924f73` → `685cba3`):
+
+- `common/lib/pauseGates.ts`: `PauseLane`, `pauseLaneFor(operationId)`, `isGateHeld` and
+ `withGateHeld`. `pauseLaneFor` asks the descriptor's `runner` BEFORE its queue key —
+ `transcode` shares `TRANSCRIPTION_QUEUE` and has no runner, so a queue-key map alone would
+ hand it the transcription pause; its answer is `null`. It walks `operationCatalog()` (all
+ seven ids), not `OPERATION_BY_ID` (the four registry entries). 6 unit tests
+ (`pauseGates.test.ts`), one of which pins the seven-id table.
+- `isGateHeld` switches on the lane and touches ONLY that lane's field, because
+ `laneGuards.test.ts:22` casts a `{digest}`-only object to `SiteSettings`.
+- Two actions, `pauseLaneAction(lane)` / `resumeLaneAction(lane)` in
+ `editor/app/operations/actions.ts`. Transcription flips the pool FIRST, then persists; a
+ failed write returns `{ok:false}` (`persistTranscriptionsPaused` used to swallow it).
+ Revalidates `/jobs`, `/workers` and `/operations/[id]` — **not `/`**, which reads its pauses
+ from polls and whose revalidation wipes the client router cache app-wide.
+- `editor/app/components/lanes/pauseControl.tsx` (`pauseLaneControl` + `PauseLaneButton`) is a
+ client module and imports `PauseLane` as a **type only**: a value import would drag
+ `operations.ts` / `settings.ts` and `fs` into the client graph.
+- `disabled` reaches the PAUSE side only, so a resume is always clickable. This fixes
+ `SweepLane.tsx:191`, which disabled both sides on `!lane.available`.
+- The wire says `held` on both sweep lanes. One reader the census MISSED:
+ `MonitorWidget.tsx:727-728` (`BackfillStrip`'s non-dense branch, "lane off" / "sweeping")
+ read `backfill.enabled` and was rewired in the same commit — the census's "nothing in
+ MonitorWidget/WidgetControls" was wrong.
+- `AutoQueueKindStatus.held` is the LIVE pool for transcription and `isGateHeld(…, "download")`
+ for download; `railStates.ts` passes it through, so a runner lane can read *Holding*.
+- New idle reason `"workers-paused"`, checked before the `no-workers` branch in
+ `autoRunner.ts`'s `limit()`, mapped in `dispatch.ts` ("transcriptions are paused") and
+ `buildActiveJobs.ts` ("transcriptions paused globally").
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,7 +3,20 @@
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-28 — **editor IA slice 4 shipped** (`a623958` → `43c4e26`, docs
+**Last updated:** 2026-08-28 — **editor IA slice 7 shipped** (`c924f73` → `685cba3`): one
+pause. `common/lib/pauseGates.ts` is the only place the four gates' polarity is known
+(`backfill.enabled` is inverted), `pauseLaneAction` / `resumeLaneAction` replace eight
+actions in two files, and `components/lanes/pauseControl.tsx` is the one control every lane
+surface draws — the dashboard, the widget, `/workers`, `/jobs/active`, both sweep panels and,
+new, the runner pages, where it sits beside Start/Drain/Stop. The gate is keyed by LANE, not
+by operation: three speaker operations share one backfill queue and therefore one pause. A
+held runner lane can read *Holding* on the operations rail for the first time, and
+`/operations/transcription` says "transcriptions are paused" rather than "no enabled worker
+to run it" (`pauseAll` disables every worker, so the runner could not tell the two apart).
+Every aria-label is byte-identical; nothing on disk changed; the widget wire fields
+`digest.paused` and `backfill.enabled` are both `held` now. See "Slice 7, as shipped" in
+`editor-operations-ia.md`.
+Previously: 2026-08-28 — **editor IA slice 4 shipped** (`a623958` → `43c4e26`, docs
`808e3fc`, e2e follow-up `97ee12a`): `/actionable` is gone and redirects to `/operations`. Its per-operation
sections are the channel-work tables on `/operations/download|transcription|digest`, report
freshness and the two refresh controls are on `/channels`, the two cleanup tables are on
@@ -224,6 +237,12 @@ nothing renders.
8. ~~**Editor IA slice 3** (per-operation settings)~~ — **DONE 2026-08-26**, `9515083` →
`43bb519`. Plan: [`editor-ia-slice-3.md`](editor-ia-slice-3.md); outcome: the dated entry
below and "Slice 3, as shipped" in `editor-operations-ia.md`.
+9. ~~**Editor IA slice 7** (one pause)~~ — **DONE 2026-08-28**, `c924f73` → `685cba3`. Plan:
+ [`editor-ia-slice-7.md`](editor-ia-slice-7.md); outcome: "Slice 7, as shipped" in
+ `editor-operations-ia.md` and the FACTS section "Verified 2026-08-28 — editor IA slice 7
+ seams". It is also **half of unified-ops step 5**: one definition and one writer among the
+ controls, but the four settings fields are still four settings fields — step 6 moves the
+ storage behind `isGateHeld`/`withGateHeld`.
---
@@ -361,9 +380,11 @@ this entry is the decisions.
- **The station label is derived, not "Speakers".** `channel-stage-selection.spec.ts` pins
"Derived data" as the honest fallback; `channel-groups.spec.ts` now asserts the same string
on the group button.
-- **`backfill*Action` names stay** — `backfillChannelAction`, `pause|resumeBackfillAction`,
+- **`backfill*Action` names stay** — `backfillChannelAction` and
`start|stopBackfillSweepAction` run or gate the LANE, and their aria-labels
(`pause backfill`, `start backfill sweep`) are asserted by specs that test the lane.
+ (`pause|resumeBackfillAction` were named here too; **slice 7 deleted them** along with the
+ other seven pause actions — the aria-labels stayed, which is the part that mattered.)
- **Plans docs were not search-replaced.** They are dated history; FACTS carries the mapping
and a line saying earlier sections are pre-rename.
diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md
@@ -154,11 +154,14 @@ dependencies allow. Sizes are S/M/L.
(`attribution-server.ts:36`). Attribution and diarization get a per-video view for free.
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. **M.**
-7. **One pause.** unified-ops step 5 as reconciled above:
- `Pause{Downloads,Transcriptions,Backfill}Button` → `PauseOperationButton({operation})`;
- the six actions in `jobs/actions.ts:172-380` collapse to one pair; the polarity
- normalization at `laneState.ts:16-19` goes away. **Keep the aria labels**
- (`backfill.spec.ts:803,814`, `widget.spec.ts:803,810`). **M, medium risk.**
+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 with a mode and one
table; `WorkersField.tsx` (556 lines) moves to `/workers`; `/scheduler` becomes
`/operations/sync`. umtool's `/` already reads "one activity list over both job
@@ -372,3 +375,70 @@ is polling. `editor/app/lib/actionable/` keeps the name as "the actionable censu
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 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.
diff --git a/plans/unified-operations-model.md b/plans/unified-operations-model.md
@@ -1,6 +1,6 @@
# The unified rule model for media-derived work
-**Status: partly built — steps 1, 2, 3 and 4 are DONE; 5 and 6 are open.** Phases A–C shipped
+**Status: partly built — steps 1, 2, 3 and 4 are DONE; 5 is HALF done; 6 is open.** Phases A–C shipped
on `feat/diarization-oom-wall` (`1f82296`, `67d2ff2`, `f9c15d5`). This file is the target they
aim at, written down so the next person does not have to re-derive it, and so the shortcuts
taken in A–C are legible as shortcuts rather than as decisions. The step list below is the
@@ -121,10 +121,21 @@ change** — every one of them moves live numbers on a 78,000-video corpus.
`laneSharesDuplicates()` covers cluster sharing. `digestBatch` calls all of them, so
behaviour is unchanged and there is one definition rather than two. 8 unit tests; the
yield branch stays covered end to end because it reads live pool state.
-5. **One pause model.** `settings.backfill.enabled`, `digest.digestsPaused`,
- `transcriptionsPaused` and `downloadsPaused` become node state on the tree. Keep the
- property all four already have and that makes them safe: a pause returns `limit() === 0`,
- which `runPool` idle-waits on, so a hold is never a stop and never re-derives anything.
+5. **One pause model — HALF DONE** (editor IA slice 7, `c924f73` → `685cba3`). What shipped
+ is one DEFINITION and one WRITER, not one storage location: `common/lib/pauseGates.ts`'s
+ `isGateHeld` / `withGateHeld` are the only place the four fields' polarity is known
+ (`backfill.enabled` is inverted), one `pauseLaneAction` / `resumeLaneAction` pair replaces
+ eight actions, and one control draws every lane's gate — including, for the first time, on
+ the runner pages. The property that makes all four safe is unchanged and now stated in one
+ header: a pause returns `limit() === 0`, which `runPool` idle-waits on, so a hold is never
+ a stop and never re-derives anything.
+
+ What is NOT done: the four settings fields are still four settings fields, not node state
+ on the tree. That migration is step 6's, and it is now cheap — every reader and every
+ writer among the controls goes through the two functions above, so the storage can move
+ behind them without touching a surface. The `LaneSettingsForm` checkbox stays a second
+ writer of `backfill.enabled` on purpose: one field, two places to set it, and they cannot
+ drift.
6. **Retire `sweepEnabled` / `sweepKinds` / `sweepChannels` / `backfill.weight`** into the
tree, last, once nothing reads them.