# Editor IA slice 3 — per-operation settings live on the operation page ## Context The vocabulary pass landed 2026-08-26 (`02ff048` → `32950aa`): the registry is `operations.ts`, says `Operation`, and every UI id space says "speakers". The user chose **slice 3 over unified-ops step 1** as the next piece (recorded in `plans/STATE.md` "Recommended next" #6 and in memory). Step 1 follows it. Today `/settings` (`SettingsForm.tsx`, 1,163 lines, one form, one `saveSettingsAction`) holds four fieldsets that configure *operations*: **Digest** (`:526-707`), **Diarization** (`:708-888`), **Speaker work lane** (`:889-981`), **Speaker attribution** (`:982-1087`) — 562 lines. `/operations/` (slice 1–2) is where an operator reads an operation's state, arms its sweep and pauses its lane, then has to go to a different page to switch it on. The IA doc's test is "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" — its settings should appear there too, and `/settings` should be the Machine page the end-state nav says it is. **Every fact below was verified at `32950aa` and is recorded in `plans/FACTS.md` "Verified 2026-08-26 — slice 3 seams"** — read that section first. `plans/STATE.md` already links this plan as `plans/editor-ia-slice-3.md`; that file does not exist yet (see step 0). Three findings that decide the design: 1. **The `*FormPresent` markers exist only because one form saves everything.** Each of the four action blocks (`settings/actions.ts` Digest `:264-343`, Diarization `:344-393`, Backfill `:394-411`, Attribution `:412-448`) is gated on a hidden input so a submit from a form lacking the block cannot read every checkbox as off. Give each block its own form and action and all four markers are dead — that is the deletion this slice makes. The `...current` spread each block already does becomes the whole isolation story, and the sanitizers are already per-block (`sanitizeDigest :937`, `sanitizeBackfill :1111`, `sanitizeAttribution :1149`, `sanitizeDiarization :1211`; `writeSettings` re-sanitizes everything at `:1679`). 2. **The lane fieldset is not per-operation.** "Speaker work lane" (`enabled`, `weight`, `concurrency`, `allowRedownload`) governs `BACKFILL_QUEUE`, shared by diarization, `attribution-text` and `attribution-diarized`. It goes beside `SweepLane` inside `SweepOperationView` (`OperationDetail.tsx:104-169`) — the one place `SweepLane` is rendered, on every member page of the lane, already carrying the shared pause and sweep under the same `data-lane="backfill"`. One definition, three renders. `LaneHeader.tsx` is the RUNNER lanes' header and is not involved; the `/operations` board does not draw the sweep lanes. 3. **Nothing in the four fieldsets is stateful** — no `useState`, everything `defaultValue`/`defaultChecked`. Outside reads are only `initial.`, `digestApps` (Digest and Attribution), the file-local `Field` (`:1128`), `DigestAppsField` (`settings/components/DigestAppsField.tsx`), and one `Link` to `/operations` in the lane fieldset. The precedent for a client form with its own scoped action is `scheduler/components/SchedulerSettingsForm.tsx` (`useActionState`, `pending`, "Saving…", `role="status"` "Saved.", `role="alert"`); the precedent for the action shape is `saveLaneOrderAction` (`operations/actions.ts:205-235`: `{...getSettings(), block}`, `writeSettings`, `revalidateOperations()` at `:30-33`). ## Step 0 — the docs already in the working tree `plans/FACTS.md` and `plans/STATE.md` are modified and uncommitted (the census and the decision). First action: write this plan verbatim to `plans/editor-ia-slice-3.md` and commit the three files together: `plans: slice 3 chosen and planned`. Nothing else in that commit. ## Order: three commits 1. **The seams** — registry `settingsBlock`, `Field` promoted, the four per-block actions written beside the old one (nothing rendered yet). tsc + unit-verified. 2. **The move** — four form components, host slots in `OperationDetail`, fieldsets and blocks deleted from `/settings`, markers gone, specs repointed. e2e-verified. 3. **Copy, pointer, docs** — every "in Settings" sentence that now points at the wrong page, a one-paragraph pointer on `/settings`, CHANGELOG, FACTS/STATE/IA doc. tsc in all six packages + `pnpm -C common test` + editor units after each; e2e once after commit 3 (detached — see memory `e2e-run-detached`; the queue lock is serial). Edit nothing while it runs. ## Commit 1 — the seams ### `settingsBlock` on the descriptor (`common/lib/operations.ts`) Off the descriptor, never off the id — the rule slice 2 set for `runner` and `sweepLaneIdFor`. Add to `Operation` (the registry-side type) and copy through to `OperationDescriptor` in `operationCatalog()`, exactly as `appliesTo` was added in `32950aa`: ```ts // The settings.json block that configures this operation, when one does. Named // here so the operation page can render that block's form without a table // keyed by operation id. Digest owns `digest`; diarization owns `diarization`; // both attribution operations share `attribution` (one block, one form, drawn // on both pages). The backfill LANE's block (`settings.backfill`) is not an // operation's and is not named here — it is rendered per lane, not per operation. settingsBlock?: "digest" | "diarization" | "attribution"; ``` Set it on `digest`, `diarization`, `attribution-text`, `attribution-diarized`. External operations and `transcode` have none. Test in `operations.test.ts`: the four carry the values above, everything else is `undefined`, and the two attribution ids share one. ### `Field` promoted `SettingsForm.tsx:1128-1162` `Field` (the superset — has `type`/`step` and the load-bearing comment about `type="number"` implying `step=1`) → `editor/app/components/forms/Field.tsx`, exported. `SettingsForm` imports it. The three other file-local copies (`ChannelForm.tsx:948`, `SiteForm.tsx:664`, `OverviewPanel.tsx:168`) are **out of scope** — say so in a comment at the top of the new file so the next reader does not "finish" it here. ### Four actions (`editor/app/operations/settingsActions.ts`, new, `"use server"`) `saveDigestSettingsAction`, `saveDiarizationSettingsAction`, `saveBackfillLaneSettingsAction`, `saveAttributionSettingsAction`, each `(prev: SaveResult | undefined, formData: FormData) => Promise` (reuse `SaveResult` from `settings/actions.ts:41`). Body of each is the existing block moved verbatim minus its marker: ```ts const current = getSettings(); const next: SiteSettings = { ...current, digest: { ...current.digest, /* fields */ } }; try { await writeSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } revalidateOperations(); return { ok: true }; ``` - `num(key, fallback)` is currently defined INSIDE the diarization block (`:353`) and reused by backfill and attribution — it becomes a module-level helper in the new file. - Digest keeps its `digestAppsJson` parse and its `"Digest app config payload is malformed"` error; keep `isDigestSectionKind` / `isDigestTimestampMode` imports. - Keep each block's existing head comment (what the spread protects: an armed sweep, the capture lane, `allowRedownload`, `textOnlyEnabled`) — the marker paragraph is deleted, the *why* is not. Add one line: the form that posts here renders every field of this block, and is the only form that touches it. - No `getWorkerPool().reconfigure` (workers-only), no `revalidatePath("/workers")`. `revalidateOperations()` covers `/operations` and `/operations/[id]`; add `revalidatePath("/settings")` only if commit 3's pointer shows anything live (it will not). - Do NOT touch `saveSettingsAction` in this commit; it still works with the markers. tsc and the existing units prove commit 1 alone changes nothing. ## Commit 2 — the move ### Four form components (`editor/app/operations/components/settings/`) `DigestSettingsForm.tsx`, `DiarizationSettingsForm.tsx`, `LaneSettingsForm.tsx` (the backfill lane), `AttributionSettingsForm.tsx`. Each is `"use client"`, takes its block as a prop (`initial: SiteSettings["digest"]`, plus `digestApps: DigestAppDescriptor[]` for Digest and Attribution), and is: ```tsx
Digest {/* verbatim */} …the fieldset's existing children, minus the hidden marker… {state?.ok &&

Saved.

} {state && !state.ok &&

{state.error}

}
``` - **Legends verbatim** ("Digest", "Diarization", "Speaker work lane", "Speaker attribution"): `settings.spec.ts:190` scopes with `getByRole("group", { name: "Digest" })` — the legend is the fieldset's accessible name and keeps that selector valid after only a `goto` change. - **Field `name`s verbatim** (`digestTimestampMode`, `diarizationEngine`, …): the actions read them and `settings.spec.ts:265` targets `select[name="diarizationEngine"]` by name. - **Button text is per block** ("Save digest settings", "Save diarization settings", "Save lane settings", "Save attribution settings"): two forms share the attribution pages and the backfill member pages, and an unscoped `/save settings/i` would be ambiguous. - `data-settings-block` is the spec hook. `role="status"` "Saved." is fine here: the SaveBar comment (`SaveBar.tsx:66-68`, "the ONLY role=status on this page") is about RUNNER pages, which get no settings form; `auto-subs-replace.spec.ts:415` (`getByRole("status").first()`) runs on `/operations/transcription`. Write that boundary into the SaveBar comment. - `DigestAppsField.tsx` `git mv`s into the new directory — its only consumer moves. - The lane form's paragraph that links to `/operations` for "which operations a sweep runs is not set here" is rewritten: the sweep scope is now **directly above this form** on the same page. Keep the sentence about why scope is not persisted separately (the resurrected-sweep bug); drop the `Link`. - Use the 3-tuple `useActionState` with `pending`, as `SchedulerSettingsForm` does. ### Host slots `OperationDetail` gains two optional props: `operationSettings?: ReactNode` and `laneSettings?: ReactNode`. `SweepOperationView` renders them after `` (`:167`): operation block first, lane block second, inside the same `section[data-lane]` — so the existing scoped selectors (`backfill.spec.ts:477`, `auto-queue.spec.ts:1210`) still find the lane's controls, and the new forms are inside the scope those specs use. Checked: nothing in the four fieldsets carries a label matching `Order`/`Reach` (exact), `/sweep$/i`, or the two sweep-scope checkbox labels. `/operations/[id]/page.tsx` (server) builds them: ```ts const settings = getSettings(); const digestApps = op.settingsBlock === "digest" || op.settingsBlock === "attribution" ? listDigestApps() : []; const operationSettings = formForBlock(op.settingsBlock, settings, digestApps); // small switch on the BLOCK, not the id const laneSettings = laneId === "backfill" ? : null; ``` `formForBlock` is a `switch` over the three block names — a table keyed by settings block, which is data the descriptor declared, not a table keyed by operation id. The digest lane's own lane facts (`sweepEnabled`, order/reach) are already in the Digest block and the `OrderReach` control, so `laneSettings` for `digest` is `null`. ### `/settings` loses the four Delete `SettingsForm.tsx:526-1087` and the four blocks in `saveSettingsAction` (`:264-448`), the four `*FormPresent` reads, and the four keys at `:491-494` — the `next` object then carries `digest/diarization/backfill/attribution: getSettings().` with the same one-line comment the `autoQueue` and `savedVideoBackup` keys already have ("edited on its own page; preserved here"). Drop `digestApps` from `SettingsForm`'s props and from `settings/page.tsx` if nothing else reads it (`workerTags` and `apps` stay). Remove now-unused imports (`DIGEST_SECTION_OPTIONS` etc., `isDigestSectionKind`, `isDigestTimestampMode`, `DigestAppsField`, `Link` if unused). ### Specs - **`settings.spec.ts:179`** (digest round-trip) → `goto("/operations/digest")`, scope to `page.locator('form[data-settings-block="digest"]')`, click `Save digest settings`, status inside that form. Move it to a new `editor/e2e/operation-settings.spec.ts`. - **`:221`** (unrelated save does not reset the digest prompt shape) → becomes the genuine cross-form test it always described: set the digest fields on `/operations/digest`, save, then `goto("/settings")`, change `admin title`, save, read `test-settings.json`. Keep its comment about React 19 resetting `
` inputs — it still explains the reload. Add a second half in the same file: save the **lane** form on `/operations/diarization` with `Run the backfill lane` ticked, then save the **diarization** form on the same page, and assert `backfill.enabled` survived — that is the property the markers used to protect, now proven structurally. - **`:265`** (sortformer round-trip) → `goto("/operations/diarization")`, scope to `form[data-settings-block="diarization"]`; the by-name selectors are unchanged. - **New**: attribution round-trip on `/operations/attribution-text` (tick `attributionTextOnly`, set `attributionModel`, save, read back) and the same form present on `/operations/attribution-diarized` (one assertion that the fieldset renders there). The attribution and lane fieldsets had **no form-driving spec at all** before. - `settings.spec.ts` keeps everything else (`:8-178`); the `page.locator("form")` selectors at `:124/:133` stay unambiguous — there is still exactly one form on `/settings`. - `backfill.spec.ts`, `attribution.spec.ts`, `worker-unit.spec.ts` set these blocks through `writeSettings()` (`e2e/helpers.ts:66`), never the form — unaffected. ## Commit 3 — copy, the pointer, docs **Copy that says "in Settings" about a switch that moved** (from FACTS; each becomes "on its operation page" with a link where the component can hold one): `OperationDetail.tsx:150` ("switched off in Settings" — it is now switched off *on this page*, below; say "below"), `SweepLane.tsx:218`, `SpeakersStage.tsx:144`, `ChannelGroupLine.tsx:184` (title), `groupActions.ts:108` (skip reason string — `channel-groups.spec.ts` does not assert it; check before changing), `DigestStage.tsx:210` and `DigestPanel.tsx:144` ("metered — disabled in Settings" → "on the Digest page"). The disk-floor sentences ("lower the floor in Settings", four sites) stay — the floor did not move. **`/settings` pointer**: one `

` at the top of `SettingsForm` (or `settings/page.tsx`): "Digest, diarization, the speaker work lane and speaker attribution are configured on their operation pages" with four links. Not a fieldset, no form. The IA rule "fold rather than add; every retired route redirects" does not apply — no route retires. **Docs**: `editor/CHANGELOG.md` (user-visible: the four blocks moved, the buttons are per block, `/settings` is machine + site settings); `plans/editor-operations-ia.md` slice 3 bullet → "SHIPPED" with a "Slice 3, as shipped" section recording the two decisions (lane block per lane not per operation; `settingsBlock` on the descriptor rather than an id table); `plans/STATE.md` dated entry + "Recommended next" (#6 done, #2 next); `plans/FACTS.md` a short "as shipped" note under the seams section (the new file paths; markers gone; the SaveBar `role="status"` boundary). ## Verification 1. After each commit: `pnpm -C exec tsc --noEmit` for `common editor export homepage umtool mcp`; `pnpm -C common test` (828+ — the `settingsBlock` test); editor units `pnpm -C editor exec tsx --test "app/**/*.test.ts"` (85+). 2. Grep gates after commit 2, over `editor/`: `FormPresent` → 0; `Save settings` → exactly the one on `/settings` (+ its spec regex); `saveSettingsAction` no longer reads `digest|diarization|backfill|attribution` form fields (grep `formData.get("digest` etc. in `settings/actions.ts` → 0); `DigestAppsField` imported only from the new directory. 3. e2e once after commit 3, detached (`setsid nohup … pnpm e2e -- `, log monitor), filenames only after `--`: `operation-settings.spec.ts settings.spec.ts auto-queue.spec.ts auto-subs-replace.spec.ts backfill.spec.ts attribution.spec.ts channel-groups.spec.ts digest.spec.ts` (whichever digest-stage spec asserts the "metered — disabled" copy; check with grep first). Known flakes from the slice-2 run (`auto-queue.spec.ts:411` race; the Videos-tab 5 s `toHaveURL` on a cold compile) are not this slice's — retry individually. 4. Manual, on the e2e fixture, never `transcripts/`: `PORT=3021 pnpm dev:test`; `/operations/digest` shows the Digest form under the lane and saving it persists to `editor/test-settings.json`; `/operations/diarization` shows Diarization then Speaker work lane; `/operations/attribution-text` and `/attribution-diarized` show Speaker attribution then Speaker work lane; `/operations/download` and `/operations/transcode` show no form; `/settings` has no operation fieldset and carries the pointer; kill the server, remove `editor/test-transcripts` and `editor/test-settings.json`, `git status` clean. ## Out of scope Unified-ops step 1 (next). The three other file-local `Field` copies. `WorkersField` (slice 8), the pause trio (slice 7), `/actionable` (slice 4). Persisted keys and field `name`s do not change. No redirect — no route retires.