# Channel priority — one tier per channel, one focus, four compiled trees **Status: SHIPPED** on branch `channel-priority/s5` (unmerged), 2026-09-11. All six slices are implemented; see **"As shipped"** at the foot of this file for the shas, the divergences and the live migration's dry-run numbers. Anchors are in [`FACTS.md`](FACTS.md#channel-priority-verified-2026-09-11). Everything above that section is the plan as written, kept as-is. Operator decisions taken, numbered as the Open questions section below: **Q1 — collapse** the two live per-lane channel orders to ONE (the model has one rank per channel). **Q2 — no**, a Paused channel still runs a MANUAL run; paused gates the sync scheduler, *Sync all* and the four auto lanes, nothing else. **Q3 — resolved by design**, by the per-operation override map (see the Model): all 15 migrate to a `sync` override and no lane's membership moves. **Q5 — no**, activating a focus never changes any lane's `enabled`. Q4 is still open and does not affect S0. The ask: *"focus on one group of channels (e.g. jeralyzer) and pause all others until the priority channels are done … rip out the current enable/disable feature on the channels page and replace it with a unified channel priority system … heavily influence the auto queue."* ## What the tree says **The premise that `ChannelConfig.enabled` exists is FALSE.** `common/lib/channelConfig.ts:47` is `enabled: boolean` inside **`AudioCheckConfig`** (`:46-57`). `ChannelConfig` runs `:59-160` and has no `enabled`. What the `/channels` row actually carries is **two negative exclusion flags**, and they gate different things: | Fact | Where | What it gates | |---|---|---| | `excludeFromSync?: boolean` | `common/lib/channelConfig.ts:113`, sanitized `:278-280` | **Sync only.** | | — scheduler skip | `common/jobs/syncScheduler.ts:99` | `continue` in the due loop, no skip log | | — status projection | `common/jobs/syncScheduler.ts:193-197` | `autoSyncEligible` false | | — manual *Sync all* | `editor/app/channels/actions.ts:466-472` | skip reason `"excluded from sync all"` | | — social due check | `common/controller/fetchPosts.ts:180` | same predicate | | `excludeFromBuild?: boolean` | `common/lib/channelConfig.ts:112`, sanitized `:275-277` | **Publishing only.** | | — index build | `common/controller/buildIndex.ts:289` | channel never enters the built index | | — stats build | `common/controller/buildStats.ts:172` | contributes no stats | | `excludeFromCleanup?` | `common/lib/channelConfig.ts:117` | the `/cleanup` aggregate total only | | Writers | `editor/app/channels/actions.ts:394-409` (build), `:411-425` (sync) | both via `writeChannelConfig` (`common/controller/channels.ts:452-463`) | | UI | `editor/app/channels/components/ChannelsTable.tsx:423-428` / `:429-434` | `ChannelBuildToggle.tsx:18-42`, `ChannelSyncToggle.tsx:18-42`; row dimmed `:406-411`; sortable `:54-63,129-163` | **Neither flag reaches dispatch.** `common/controller/autoRunner.ts`, `common/controller/operationBatch.ts` and `common/jobs/autoQueuePolicy.ts` contain zero `excludeFrom*` references. `listChannelMeta` (`autoRunner.ts:288-296`) maps `listChannelConfigs` straight to `{slug, platform}` with no filter, so today an excluded-from-sync channel is still drawn by every lane. Snapshot generation, `recencyIndex`, the export app and `mcp/src` never read a channel config at all. **The lane trees are already a priority system, and the live ones are trivial.** From the repo-root `settings.json` (gitignored): | lane | enabled | order | held | root | |---|---|---|---|---| | transcription | true | listed | false | strict, 10 children: 9 bare `{type:"channel"}` leaves then `{type:"all"}` | | download | true | listed | false | strict, 10 children: 9 bare `{type:"channel"}` leaves then `{type:"all"}` | | digest | false | cheapest | false | strict, 1 child `digest-all` `{type:"all"}` | | backfill | false | newest | false | strict, 1 child `backfill-all` `{type:"all"}` | **Every one of the 22 live leaves is a bare channel or `all` leaf. Zero carry `match.bucket`; zero carry `match.operation`.** The two hand-made lists disagree: transcription names `quartering-live, the-quartering-rumble, the-quartering, HasanAbiVODs3, hasanabi, rekietalaw-rumble, nux-taku, nuxanor, leaflit-rumble`; download names `quartering-live, the-quartering, the-quartering-rumble, nuxanor, darlingstrawb, chibi-reviews, destiny, omnivods-odysee, piratesoftware`. Six channels are ranked in one lane and not the other, and the three shared Quartering channels are in two different orders. **A strict group is already "hold the rest until the higher one is empty".** `pick()` (`common/jobs/autoQueuePolicy.ts:487-513`) filters children to those with work (`:501`) and on `mode === "strict"` descends into the FIRST of them (`:503-505`). It is re-asked on every single grant, so: focus work present ⇒ nothing below it is picked; focus work exhausted ⇒ the next child runs; new focus work arrives ⇒ the very next pick retakes the lane. The runner rebuilds `pending` every tick from fresh snapshots (`autoRunner.ts:1087-1092`) and re-reads the policy each iteration (`:1093-1095`). **Sites are a clean partition.** 63 distinct slugs across six `transcripts/sites/*/site.json`, **zero in two sites**; 69 directories under `transcripts/channels/` (68 real + `.stfolder`), so 5 are in no site (`angryjoeshow`, `jfg-tonight`, `leaflit-rumble`, `omnimirror`, `piratsoftware-x`). `jeralyzer` = 30, `anilyzer` = 22, `hasanalyzer` = 5, `bonnellyzer` = 3, `rekietalyzer` = 2, `jasolyzer` = 1. **Sync ordering today.** `selectDueChannels` (`common/jobs/syncScheduler.ts:85-127`) gates on `scheduler.enabled` (`:87`), quiet hours (`:88-92`), `!config.url` (`:98`), `excludeFromSync` (`:99`), interval ≤ 0 (`:101-102`), already-running (`:104-107`), failure backoff (`:109-118`) and not-due (`:120-121`); it sorts **most-overdue-first** at `:125`, ties resolving alphabetically because `listChannelConfigs` returns slug order (`common/controller/channels.ts:401-403`). The tick caps the list at `maxConcurrentSyncs - running` (`editor/app/scheduler/runTick.ts:127-131`). No pause gate is consulted in selection (`pauseGates.ts:64-67`: sync's lane is `null`); dispatch refuses when the **download** lane is held (`editor/app/channels/[slug]/pipelineActions.ts:109-117`). ## Decision **One document in `settings.json`, one tier per channel plus one focus selector, compiled into the four `AutoQueuePolicy.root` trees by the model's own writer. Paused is a filter on the runner's channel list, not a tree shape.** 1. **`settings.json`, not 68 `config.json` files.** A focus is one corpus-wide fact with an ordering *between* channels; 68 files cannot express a total order and cannot be written atomically. `parseChannelConfig` is allow-list style (`channelConfig.ts:211-375`) and silently drops unknown keys, so a per-channel rank would need a schema change in the file that every downloader, sweep and build reads. `settings.json` already holds every other dispatch decision (`autoQueue`, `syncScheduler`) behind one sanitizer and one writer. 2. **COMPILE, not consult.** The deciding fact is the pair above: strict descent (`autoQueuePolicy.ts:503-505`) is *exactly* the focus/hold semantics the operator described, re-evaluated per pick at zero cost, and **the live trees contain nothing a compiler would destroy** — 22 leaves, all bare. Consulting the model from the claim ladder would mean a second priority mechanism beside the tree, two things to explain on `HowPriorityWorks.tsx`, and a new re-evaluation clock. Compiling means **no dispatch code changes at all**: `buildPendingByLeaf`, `selectNextWork`, `operationBatch`, `laneLimit`, `pauseGates` and every `held` key are untouched, and "a zero limit is a hold, never a stop" is preserved trivially because nothing new ever returns a limit. 3. **Paused is removed from the channel LIST, not from the tree.** A tree cannot express exclusion (an `{type:"all"}` catch-all matches everything, and first-match-wins would let a catch-all placed above the Low group swallow Low's work). `listChannelMeta` (`autoRunner.ts:288-296`) is the single source of the channel list for **both** the runner loop (`:1080-1092`) and the status panel (`computeLeafPending`, `:574-582`), refreshed on a 30 s TTL (`CHANNEL_LIST_TTL_MS`, `:141`). Filtering Paused there makes a paused channel invisible to all four lanes, catch-all included, in one predicate. 4. **`excludeFromSync` dissolves into a `sync` OVERRIDE, not into the Paused tier; `excludeFromBuild` stays.** The three are different axes: one is the sync cadence, one is dispatch, one is publishing, and the brief's own rule is that the lowest tier must not gate export. A per-operation override says "stop syncing, keep everything else" exactly, so the migration moves no lane's membership. The `/channels` row keeps its Build toggle and loses its Sync toggle. 5. **No drag-and-drop, and no per-lane RANK.** Tier is a ``, `aria-label={`tier for ${slug}`}`, posting `setChannelTierAction(slug, tier)`. `ChannelBuildToggle` is untouched. - Row selection: checkboxes + a bulk bar, copying `SyncConsole.tsx:29,107,115,169,196-199` and `BulkCadenceBar.tsx:37,41` verbatim rather than inventing an idiom. Bulk actions: **Set tier**, **Focus these**, and a **Focus site: ``** menu built from `listSites()` (no selection needed). - **Advanced: per-operation selects behind a disclosure**, beside the tier control, plus a **Sync only** preset (and its inverse, which is what every migrated `excludeFromSync` channel already carries). The row shows the base tier and a marker when any operation is pinned; the disclosure is where the five operations are set. - Sort key `sync` (`ChannelsTable.tsx:54-63,129-163`) becomes `tier` (tier order, then rank, then slug). Row dimming (`:406-411`) keys off `tier === "paused"`. - `channelGroupSections.ts:124`'s excluded-from-sync section becomes the Paused section. - **One writer.** `saveChannelPriorityAction` in `editor/app/channels/actions.ts` is the only function that writes `settings.channelPriority`; every control above funnels through it, and it **recompiles the four roots in the same `saveSettings` call** — `autoQueue[lane].root = compileLanes(...)[lane]`, spreading each policy so `held`, `snoozeUntil`, `enabled`, `order` and `maxWorkers` survive (the rule `withGateHeld`/`saveAutoQueueAction:47-80` already state). `createChannel` / `deleteChannel` recompile too, so a new channel gets a real leaf rather than the net. 6. **The focus banner** — `editor/app/channels/components/FocusBanner.tsx`, rendered on `/channels` (`page.tsx`, above the table at `:192`) and on the lane consoles at `editor/app/operations/components/OperationDetail.tsx:127` — between `HowPriorityWorks` and `RunnerOperationView`, i.e. **outside** the `
` that `RunnerOperationView` opens (`:89-93`) and whose contract comment (`:22-36`) reserves `role="status"` and forbids a nested `
`. Content: `Focus: (N channels) · pending in this lane · M channels held`, and an **End focus** button posting `endFocusAction()` (which sets `focus: {kind:"none"}` and recompiles). The numbers come from `computeLeafPending` (`autoRunner.ts:574-662`) — already computed for the status panel, so the banner costs one sum over `counts` keyed `prio-focus-*` and one over the rest. On `/channels` the banner shows the per-lane line only for lanes whose policy is `enabled`. 7. **A held row says why.** `ChannelsTable` renders `Held — focus: ` beside the tier of any non-focus channel while a focus is active and the focus set still has pending work in at least one enabled lane. Derived from the same `focusSummary`, not from a new read. 8. **`HowPriorityWorks.tsx`** gains one paragraph: the tree is *generated* from the channel priority model, and `PolicyTreeEditor.tsx` (360 lines) becomes read-only for compiled groups — it keeps editing `enabled`, `maxWorkers`, `order`, `replaceAutoSubs` and any hand-added `bucket`/`operation` leaf, and shows compiled channel leaves with an "edit on /channels" link. It is not deleted: the bucket/operation axis it can express has no equivalent in the priority model. ## Migration Live data: **15 of 68 channels carry `excludeFromSync: true`** — `community-notes`, `angryjoeshow`, `cornbreadman`, `friendofrc`, `hex-headquarters`, `mevsme`, `omnivods-odysee`, `exclusively-games`, `rcflightschool`, `rcspotlight`, `teamrcn`, `the-incredible-salt-mine`, `steven-crowder`, `midwestly`, `redbar` — and **zero** carry `excludeFromBuild`. One of the 15, `omnivods-odysee`, is also the 8th ranked leaf of the live download tree: today it is excluded from sync yet still drawn by the download lane. **The mapping is LOSSLESS, and that is what the override map is for.** `excludeFromSync` meant "stop syncing", not "stop everything", so it becomes `{tier: , overrides: {sync: "paused"}}` — the base tier stays `normal` and the rank (if the channel has one) stands. `omnivods-odysee` keeps downloading exactly as it does today; **no lane's membership moves**, which makes the migration a settings rewrite rather than a behaviour change. An operator who wants one of the 15 paused outright sets its base tier afterwards, deliberately, on `/channels`. - **Pure function**, `channelPriorityFromLegacy(configs, autoQueue): ChannelPriority`, in `common/lib/channelPriority.ts`, in the `laneMigration.ts` style (no I/O, idempotent over its own output, asserted through `sanitizeChannelPriority`): - `config.excludeFromSync === true` → `overrides: {sync: "paused"}` on the channel's entry, never a base tier. A channel that also has a lane leaf (`omnivods-odysee`) keeps its rank and every lane it was drawn by. - A channel named by a bare channel leaf in **either** the download or the transcription root → `{tier: "normal", rank: }`, where the index is its position among that root's **bare channel leaves** (a dense order; identical to the leaf index when every leaf is bare, which all 22 live leaves are — so a bucket or operation leaf the model cannot express leaves no hole). Channels ranked in one lane only keep that lane's index. - Everything else → absent (normal, unranked). - `focus: {kind: "none"}`. - **It is NOT run from `getSettings`.** That function is synchronous and reads one file; the migration needs 68 `config.json` reads. It is a one-shot script, `common/bin/migrate-channel-priority.ts`, run offline with `tsx` (never a second editor), that reads the configs and `settings.json`, writes `channelPriority`, recompiles the four roots and prints a diff. Idempotent: a second run is a no-op because the document already exists. - **The order collapse is the migration's ONE behaviour change, and must be measured.** Six channels are ranked in one lane only, and the three Quartering channels are in different orders in the two lanes; after the migration both lanes get one order. Gate it with `plans/tools/phase1-numbers.ts` before/after over the live corpus, the way every Phase 1 slice was gated — the `*_leaves` lines will move by construction, so the number that must not move is each lane's total pending count. - `excludeFromSync` stays in the type and the sanitizer as the migration's **input only**, exactly as the four retired pause fields did (FACTS.md, slice 1.4), and is deleted with its sanitizer, its action (`actions.ts:411-425`), its toggle and `channel-sync-toggle.spec.ts` in the last slice. ## Tests **Unit** — `common/lib/channelPriority.test.ts`, run by `pnpm --filter yt-dlp-transcript-common test` (`common/package.json` glob covers `lib/*.test.ts`): 1. `sanitizeChannelPriority`: unknown tier → normal; junk rank dropped; blank/dup slugs; absent document → `{focus:{kind:"none"}, channels:{}}`; idempotent over its own output. 2. `resolveFocusSlugs`: site focus resolves through a site's `channels[]`; an unknown siteId resolves to `[]` (and therefore compiles **no** focus group — a typo must not hold the whole corpus); channel focus keeps order and drops unknown slugs. 3. `compileLaneRoot`: group order focus → normal → low → catch-all-last; paused slugs never appear; rank ordering with unranked tail; ids are `prio-*`; an empty focus emits no focus group; a per-operation override moves a channel on **one** lane's tree and not the others. The half that must import the engine — *the output survives `sanitizeAutoQueue` unchanged*, with no id reassigned — is `jobs/channelPrioritySanitize.test.ts`. 4. **The focus/hold/done transition, asserted through the real engine**: build two `ChannelWork` objects, compile a tree with channel A focused, and drive `buildPendingByLeaf` + `selectNextWork` — every pick is A's while A has work; the first pick after A's list is drained is B's; re-adding an id to A's bucket makes the next pick A's again. This is the whole design in one test. It lives in **`common/jobs/channelPriorityCompile.test.ts`**, not in `lib/`, because it must import `jobs/autoQueuePolicy` and `architecture.test.ts` forbids `lib/ → jobs/` — the same reason `laneMigration.test.ts` sits in `jobs/`. 5. `channelPriorityFromLegacy`: the live shape (9+9 leaves, 6 one-lane channels, 3 reordered); an `excludeFromSync` config becoming a `sync` override only; and **the 15-channel case round-tripping with no lane change** — every channel still on every lane's compiled tree, only the `sync` operation losing anyone. 6. Sync ordering: `selectDueChannels` with one focus channel 1 min overdue and one low channel 1 day overdue returns the focus channel first; a paused channel is never due. **e2e** — from a `git worktree` with a composed fixture site in its `export/public`, detached behind the queue lock (`setsid nohup`, ~25 min), never in the primary checkout while `editor/content` is a symlink (STATE.md). - `channel-priority.spec.ts` (new): the tier `