commit 4495d56a049dd94c04716bdf4f5b65d79a8fe6d9
parent 5344f904417f1218dc0ce6a82074e916d0c89da7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 17:01:03 -0400
FACTS: the 2026-09-22 release, and four entries that were no longer true
Anchors for everything the three branches landed, each read off the tree at
4715bc6a rather than off the commit messages. The ones worth the ledger space:
- `channelsExclude` is part of `hashCuratedRules`, so shipping it changes the
rules hash for every existing rule set and the FIRST build-index after deploy
walks every record (~80 s). An 80 s re-derive on a deploy that "only added a
field" is correct, and the build log says which branch ran.
- `--wait` POLLS `/api/jobs/<id>/log` for `{content,nextOffset,status,...}`. It
is not a stream, and a dropped poll is not a job failure — reasoning about it
as a held-open connection is how the old hand-poll workaround got written.
- `computeLeafPending`'s reads were on DISK, four times a poll. Corrects
one-core-phase-3.md:466-468, whose parenthetical said in-memory.
- The `shipsPwa` guard stops a client-import crash and does NOT make hub
detection work client-side. That distinction has now been got wrong twice.
Four in-place corrections, each left in place with a superseded banner rather
than deleted, because the failure they describe is still the reasoning:
`editor/content` (gone, and gitignored, so `git add -A` can no longer stage it),
`e2e:2origin` (GREEN 3/3 — a red run is a regression now), `shipsPwa`, and in
curated-tags.md the site-layer "may append rules" sentence (it may not: rules
are dropped on read AND on write) and the siteId/siteIds asymmetry (gone).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 321 insertions(+), 4 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3385,6 +3385,12 @@ CONTENTS diff clean. Anything else differing is a real change to the wire.
### `editor/content` is a symlink out of the repo and it panics Turbopack in dev
+> **SUPERSEDED 2026-09-22 — the symlink is gone and the path is gitignored.** `ls -l
+> editor/content` fails on `main` @ `766e0873`, and `.gitignore:110` is `/editor/content`
+> (`1e0b10a9`), so `git add -A` can no longer stage it. `pnpm e2e` runs in the primary
+> checkout. See "`editor/content` is GONE" at the foot of this file. Everything below is kept
+> as the record of the failure mode, in case the symlink is ever re-created.
+
`editor/content -> /home/user/Projects/recipe-content` (untracked, made 2026-08-31).
Tailwind's automatic source detection follows it out of the project and Turbopack panics
compiling `editor/app/globals.css`: `FileSystemPath("editor").join("../../recipe-content")
@@ -3691,6 +3697,9 @@ killed by it, is in `plans/deflake-e2e.md`'s "4, as shipped".
### The `editor/content` symlink still blocks e2e in the primary checkout
+> **SUPERSEDED 2026-09-22 — it does not.** The symlink is absent and `/editor/content` is in
+> `.gitignore` (`1e0b10a9`). See "`editor/content` is GONE" at the foot of this file.
+
Unchanged from the Phase 0 entry, plus one thing Phase 1 learned twice: **`git add -A` stages
it** (it is untracked, not ignored), which carries the Turbopack panic into every worktree of
that commit. Add by path. A worktree also needs a composed fixture site copied into its
@@ -4064,6 +4073,13 @@ own parse: **12 valid header rules before, 14 after**. The real before/after is
### `shipsPwa` is server-called only
+> **AMENDED 2026-09-22 (`d0df901b`): the guard is there now** —
+> `typeof process !== "undefined" && process.env.INSTANCE_MODE === "hub"`,
+> `common/lib/archive/contract.ts:203-207`. Read the amendment at the foot of this file before
+> concluding anything from it: **the guard stops a client import from crashing; it does NOT
+> make hub detection work client-side.** The paragraph below is otherwise still the reasoning,
+> and its correction about inlining is still the thing people get wrong.
+
`common/lib/archive/contract.ts:183`. It reads `process.env.INSTANCE_MODE` BARE, with no
`typeof process` guard, unlike `io-stats.ts` and the page-cache knob. That is safe only
because of who calls it: `common/bin/compose-site.ts` (a build script) and
@@ -4099,6 +4115,12 @@ absent file. Neither answers a cue WINDOW, so neither can cut a clip in the wron
### `e2e:2origin` is known-red on the base and Phase 2 did not touch it
+> **CLOSED 2026-09-22 (`f40c4257`, `7a38b60b`, `2e13c0ab`).** The suite is GREEN, 3/3 — the
+> first time since hub `/ask` existed. `export/app/ask/AskHub.tsx` needed the WHOLE provider
+> stack, not an opt-out of prerendering. Details at the foot of this file. **Stop treating
+> `e2e:2origin` as known-red; a red run is now a regression.** The paragraph below is the
+> record of the failure.
+
`playwright.2origin.config.ts:16` → `e2e-2origin/globalSetup.ts:142` shells
`pnpm run build:hub`, and the hub build dies prerendering `/ask`:
`Error: useSearchSession must be used within a SearchSessionProvider`, from
@@ -4982,3 +5004,268 @@ or an explicit clips total, a count on the storage page, and an eviction rule").
specs leave clip windows in `channels/testchan/data/<id>/clips/`, which
`projectCache` folds in — so which file `containing()` picks depends on what
ran first. Not a capability, not skipped, and it predates this work.
+
+## The 2026-09-22 release — curated-tags follow-ups + debts sweep (`main` @ `766e0873`)
+
+Three branches off `4ac8ceda`, merged in order: `1a011d96` alone, then `tags/rules-and-ops`
+(`9837f066`), `export/tags-followups` (`cb254919`), `editor/debts` (`766e0873`).
+
+### A rule can exclude channels, and the exclusion wins — and it re-derives the corpus once
+
+- `CuratedTagRule.channelsExclude?: string[]` (`common/lib/curatedTags.ts:54`), compiled to a
+ `Set | null` on `CompiledTagRule` (`:459`, filled at `:503-506`). `ruleApplies` (`:531`)
+ tests the allow-list FIRST (`:535`) and the exclude SECOND (`:539-541`), so **a channel
+ named in both is excluded**. That ordering is the contract, not an accident of the code.
+- **The trap is the hash, not the rule.** `channelsExclude` is part of the shape
+ `hashCuratedRules` digests (`common/controller/curatedTagsIndex.ts:75`, the field at `:86`),
+ so merely SHIPPING this commit changes `curatedRulesHash` for every existing rule set. The
+ first `build-index` after the deploy therefore takes the `rulesChanged` branch of
+ `reapplyCuratedTags` (`curatedTagsIndex.ts:333`, branch at `:421-434`), which walks
+ **`sums.getRange({snapshot: false})` — every record in the corpus** — rather than the
+ targeted `byChannel` scan an assignment-only change takes. Measured ~80 s on the live
+ corpus, once. The build log says which branch ran: `curated tags: rules <hash8> (changed)`
+ (`:484`, combined line at `:487-490`; the full-rebuild variant is at `:359`). A reviewer who
+ sees an 80 s re-derive on a deploy that "only added a field" is looking at this, and it is
+ correct.
+
+### A site layer carries no rules — dropped on READ and on WRITE
+
+- `coerceTagDef(raw, layer)` (`common/lib/curatedTags.ts:214`) forces `rules: []` when
+ `layer === "site"` (`:235-238`), and `readSiteTags` / `writeSiteTags`
+ (`common/lib/curatedTagsStore.ts:94-113`) both call `sanitizeTagsConfig` with
+ `{layer: "site"}`. So a rule hand-written into `sites/<id>/tags.json` is **removed from the
+ file the next time the editor writes it**, not merely ignored at merge. `mergeTagDefs`
+ (`:371`) has no rule branch left to reach; the header comment stating the rule is
+ `curatedTagsStore.ts:9-17`.
+- Why, restated because it is the kind of rule people "fix": a record is shared by every site
+ that carries its channel, so a site-layer rule could only tag records the OTHER sites also
+ publish. The site layer is presentation — `label/groupLabel/color/order/hidden`.
+
+### `--wait` polls a log route, and a dropped poll is not a failure
+
+- `followJob(jobId, quiet, opts = {})` — `scripts/archilyzer-ops.mjs:296`, with
+ `opts.fetch` / `opts.sleep` / `opts.now` injectable (`:297-300`), which is what makes it
+ testable (`scripts/archilyzer-ops.test.mjs:8,156`).
+- **`/api/jobs/[id]/log` is a POLLED JSON route, not a stream.** It returns
+ `{content, nextOffset, status, queueKey, queuePosition}`
+ (`editor/app/api/jobs/[id]/log/route.ts:55-61`) and `pollLog` re-fetches with `&from=<n>`
+ (`archilyzer-ops.mjs:311-322`). There is no SSE here — do not reason about `--wait` as if a
+ connection were being held open.
+- A poll that throws returns `null` rather than propagating (`:311-322`, the reason spelled
+ out at `:270-278`: *a poll failure is not a job failure*). After three consecutive failures
+ it asks `/api/jobs/active` (`stillListed()`, `:324-332`) and keeps waiting if the job is
+ still there. Probe failures are bounded by `MAX_PROBE_FAILURES = 10` (`:64`, enforced
+ `:383-386`), and `--wait-timeout` bounds the whole wait (`:123,135,138,142-143`, enforced
+ `:390-396`).
+- **A recovered poll that answers `queued` or `running` is not an outcome** (`94750a1b`) —
+ the earlier shape treated any successful re-poll as terminal.
+
+### `reqSiteIds` — both build routes take `siteId` or `siteIds`, and a bad id is 400 before any job
+
+- `export function reqSiteIds(body: OpsBody): string[]` —
+ `editor/app/api/ops/_lib.ts:179-199`. It throws `OpsInputError` for both keys at once
+ (`:183`), for neither (`:189-191`), and for any id failing `isValidSiteId` (`:195-197`).
+- Both `build-site/route.ts` and `build-deploy/route.ts` call it, and the whole array is
+ validated before the fan-out loop's body runs — so **an invalid site id can never start a
+ partial fan-out**. `OpsInputError` is caught in `ops()` (`_lib.ts:77-79`) and mapped by
+ `opsFail`, whose default status is 400 (`:35-41`).
+- `build-deploy` returns `{ok: true, jobs, skipped}` plus `jobId` only when `jobs.length === 1`
+ — a caller that reads `jobId` unconditionally breaks on a multi-site body.
+
+### `wt rm` finds the directory `wt add` made
+
+- `worktreeDirFor(main, name)` — `scripts/worktree.mjs:81-84`. Absolute names pass through;
+ everything else is flattened with `name.replace(/[^A-Za-z0-9._-]/g, "-")` under
+ `dirname(main)`, so `tags/site` → `…/tags-site`. `cmdRm` (`:289-306`) calls it at `:300`.
+ One function for both verbs is the point: the flattening cannot drift between them.
+ Covered by `scripts/worktree.test.mjs:17,19,25,27,36-37,43`.
+
+### The four-lane status poll reads the corpus ONCE
+
+- `computeLeafPending(kind, paths = getPaths(), shared?: {configs?, state?})` —
+ `common/controller/autoRunner.ts:919-927`. Without `shared` it reads the channel configs and
+ `.auto-queue/state.json` itself, which is four corpus reads per `/operations` tick.
+ `buildAutoQueueStatusPayload` (`editor/app/operations/status.ts`) now reads both ONCE via
+ `Promise.all` and passes the pair to all four lanes.
+- **This CORRECTS `plans/one-core-phase-3.md:466-468`**, which says the two are "still called
+ four times per poll … (not claimed fixed; the reads are in-memory)". The parenthetical was
+ wrong even then: `computeLeafPending`'s reads were on disk. `getAutoRunnerStatus` is still
+ per-lane and IS in-memory.
+
+### `shipsPwa` now carries the `typeof process` guard (amends "server-called only")
+
+- `common/lib/archive/contract.ts:203-207`:
+ `site.pwa === true || (typeof process !== "undefined" && process.env.INSTANCE_MODE === "hub")`.
+- **The guard stops a crash; it does not make hub detection work client-side** — the comment
+ at `:183-198` says so outright. `INSTANCE_MODE` is neither `NEXT_PUBLIC_`-prefixed nor in a
+ `next.config.ts` `env:` block, so Next never inlines it and a client bundle reads
+ `undefined`. What changed is only the failure mode of a future client import: it evaluates
+ to `false` (no PWA) instead of throwing `ReferenceError: process is not defined`. Hub
+ detection stays server-only. The earlier entry's "**A future CLIENT caller owes the guard**"
+ is now paid; its correction about inlining still stands and is still the thing people get
+ wrong.
+
+### Tag chip groups fold, and the summary says how many are selected
+
+- `FiltersPanel` (`common/components/FiltersPanel.tsx:135`) renders one `<details>` per tag
+ group (`:889`, `data-testid="tag-chip-group"`), open-state from
+ `collapsedTagGroups: Set<string>` (`:786`) via `toggleTagGroup` (`:789`) and
+ `open={isOpen}` where `isOpen = !collapsedTagGroups.has(key)` (`:877`). The `<summary>`
+ shows `{selectedCount} selected` (`:923`, in `data-testid="tag-group-selected"` at `:920`)
+ only when `selectedCount > 0`. The state is LOCAL to the panel — folding a group is not a
+ filter and never touches the URL.
+
+### `SCOPE_LABELS` is the one scope-label table
+
+- `export const SCOPE_LABELS: Record<LayerScope, string>` —
+ `common/components/QueryLeafView.tsx:50-56`:
+ `transcripts: "Transcripts"`, `chat: "Live chat"`, `posts: "Posts"`,
+ `metadata: "Title / channel"`, `description: "Description"`, **`tags: "Keywords"`** (the
+ yt-dlp-keyword scope — the naming hazard, kept out of the curated-tag vocabulary).
+- Used by its own `<select>` at `:215` and imported by `SearchResults`
+ (`common/components/SearchResults.tsx:36`) for the leaf-section heading at `:706`.
+- **Checked 2026-09-22: nothing else in `common/components` or `export/` holds a second copy**
+ of this map. The remaining string matches are the definition, comments about it, and e2e
+ specs asserting rendered text. The two surfaces are label-consistent by construction, not by
+ coincidence — so a relabel is a one-line change.
+
+### `e2e:2origin` is GREEN — the ~:4100 entry is CLOSED
+
+- The hub `/ask` prerender crash (`useSearchSession must be used within a
+ SearchSessionProvider`) is fixed by **giving the page the whole provider stack**, not by
+ opting out of prerendering. `export/app/ask/AskHub.tsx:41-52` mounts, in order,
+ `PlayerProvider` → `MultiSiteDataProvider` → `SearchSessionProvider` → `AskChat`, with
+ `TranscriptModal` and `PostModal` as siblings inside `PlayerProvider`. The comment at
+ `:31-38` records that none of them is optional and that "the fix is the stack, never an
+ opt-out of prerendering"; there is no `force-dynamic`, no `dynamic =`, no `ssr: false` on
+ that route or the workspace layout.
+- `export/e2e-hub/ask.spec.ts` covers it (`test.describe("hub /ask")` at `:26`).
+ `pnpm --filter export run e2e:2origin` passes **3/3** — the first green run since hub
+ `/ask` existed. The known-red note above it is history; do not skip the suite on its word.
+
+### A curated-tag filter takes posts OUT of an MCP search
+
+- Posts carry no `curatedTags` — the field is on `TranscriptSummary`, and a post is not a
+ video — so a search that both asks for posts and filters by tag would scan every post page
+ to return nothing. It doesn't: `postsSkippedForTagFilter` (`mcp/src/search.ts:562-563`) and
+ `const wantPosts = postsAsked && !postsSkippedForTagFilter` (`:564`) gate the posts pass at
+ `:710`; the query-tree path does the same through `wantsPosts` (`:1156-1159`).
+- It is REPORTED, never silent: `postsScanned` gains
+ `skippedForTagFilter: boolean` beside `requested` / `channels` / `pages`
+ (`mcp/src/search.ts:241-246`, assembled `:792-797`), and `describePostsPass`
+ (`mcp/src/server.ts:1367`, called `:1247` and `:1459`) emits, verbatim
+ (`:1371-1374`):
+
+ > `posts: skipped — a tag filter was given and posts carry no curated tags (the export UI does the same)`
+
+ The parenthetical is load-bearing: the export UI drops the posts section under a tag filter
+ for the same reason, so the two surfaces agree and neither is a bug report.
+
+### The channel page can evict that channel's clip windows
+
+- `ClipWindowsCard` — `editor/app/storage/components/ClipWindowsCard.tsx:62-79`,
+ `{clipsBytes, slug?, blockedReason?}`. With no `slug` it is the corpus-wide card on
+ `/storage`; the channel Storage panel mounts it WITH one
+ (`editor/app/channels/[slug]/components/stages/StorageStage.tsx:187,200,236-239`), and the
+ panel's own `blockedReason` — `channelMediaBusyReason`, computed at
+ `editor/app/channels/[slug]/page.tsx:595-596,625` — disables it (`:87`, `:194`, `:205`).
+ One card, two scopes; there is no second eviction UI.
+- **The by-age caveat is shown, not buried** (`:122-127`): *"Eviction is by age only. Nothing
+ here can know whether a report still cites a window — those manifests live in umtool
+ projects this editor cannot see. An evicted window is re-fetchable, so the cost of getting
+ this wrong is one fetch, not data. Preview first."* Every surface that prunes `clips/` says
+ this, because there is no reference count and there cannot be one.
+- `evictClipWindowsAction` (`editor/app/storage/actions.ts:421-452`) validates the slug
+ (`:439-445`, `isValidChannelSlug` then `channelExists`) before it walks anything;
+ `editor/app/api/ops/evict-clips/route.ts` is a thin adapter onto the same action.
+
+### Rename and delete refuse while a channel's media is busy
+
+- `channelMediaBusyReason(slug, what?): string | null` —
+ `editor/app/channels/lib/mediaBusy.ts:33-53`. Delete calls it at
+ `editor/app/channels/actions.ts:609` (`"deleting it"`), rename at `:679`
+ (`"renaming it"`); the same function feeds the Storage panel's `blockedReason`, so the
+ button and the action can never disagree.
+- The `.relocating.json` refusal thrown by `deleteChannel` itself reaches the form as
+ `{error}` rather than a 500 (`actions.ts:618-621`) — the operator could not read it before.
+
+### Sync all skips a channel whose media drive is not mounted
+
+- `syncAllChannelsAction` passes a `skip` to `queueForSlugs` that calls `inspectChannelMedia`
+ (`editor/app/channels/actions.ts:571`) and returns, verbatim (`:572-574`):
+ ``` `media ${media.status}: ${media.detail ?? "not reachable"}` ``` for any status that is
+ neither `ok` nor `in-place`. **An unmounted drive is not an empty channel** — without this,
+ a sync-all would have walked the absent `data/` and read the whole channel as undownloaded.
+- **Do not cite `common/controller/autoRunner.ts:582-583` for this string.** The scheduler's
+ own skip log is `media ${status} — ${detail}` (em dash, no colon). Two call sites, two
+ formats; only the colon form is the sync-all action's.
+- Driven end to end by the real button:
+ `editor/e2e/channel-storage.spec.ts:794` *"Sync all skips a channel whose media drive is not
+ mounted"*, asserting `media unreachable: … drive not mounted` (`:816-819`).
+
+### Worker auth is 503 when no token is configured, not 401
+
+- `authorizeWorkerRequest` (`common/lib/workerToken.ts:23-43`) returns
+ `{ok: false, status: 503, error: "worker endpoint disabled (set WORKER_TOKEN to enable)"}`
+ when `WORKER_TOKEN` is unset (`:25-31`) — **before** it looks at what the request carried,
+ so no credential can talk its way past an unconfigured endpoint. 503 and not 401 because
+ the endpoint is disabled, not the caller rejected.
+- `common/lib/workerToken.test.ts:39-59` pins it (*"no token configured: every request is 503,
+ whatever it carries"*), and `editor/app/api/test/worker-token/route.ts` exposes it to e2e.
+
+### EVERY `/api/test/*` route 404s unless `EDITOR_TEST_ROUTES=1`
+
+- `testRouteDenied()` — `editor/app/api/test/_guard.ts:23-26` — returns a 404
+ `{error: "Not Found"}` unless `process.env.EDITOR_TEST_ROUTES === "1"`, and returns `null`
+ (proceed) otherwise. All five routes under `editor/app/api/test/` call it:
+ `stuck-job`, `invalidate-cache`, `resume-lane`, `worker-token`, `uncaught-count`.
+ **Checked 2026-09-22: none is missing it.** A new route under that directory owes the first
+ line of its handler to this guard.
+- `EDITOR_TEST_ROUTES=1` is set by `dev:test` and `start:test` only
+ (`editor/package.json:8-9`), so a production `start` serves 404 for all of them. It is a
+ 404 and not a 403 on purpose: the route does not admit it exists.
+
+### S0-pause — the four legacy pause fields are DELETED, and `held` defaults per lane
+
+- Gone from `SiteSettings`, from every sanitizer, and from `writeSettings`' merge literal
+ (`common/lib/settings.ts:1713-1718`): `transcriptionsPaused`, `downloadsPaused`,
+ `digest.digestsPaused`, and the inverted `backfill.enabled`. A `settings.json` that still
+ spells one is read past on load and **loses it on the next write**. `legacyGateHeld` and
+ `migrateHeldToLanes` no longer exist anywhere in the tree.
+- **`common/lib/laneMigration.ts` still exists** and is not a leftover: it hosts
+ `migrateSweepsToLanes` (slice 1.3's sweep-scope migration), which is live. Its header
+ (`:6-12`) says the pause migration is the part that went.
+- **The default is the retired fields' reading, preserved.** `defaultHeldFor(lane)`
+ (`common/jobs/autoQueuePolicy.ts:684-686`) is `lane === "backfill"`, applied by
+ `sanitizeAutoQueue` at `:749` (`held: typeof r.held === "boolean" ? r.held : defaultHeldFor(lane)`)
+ and by `defaultAutoQueuePolicy` at `:695`. The three paused-flags defaulted false; the
+ inverted `backfill.enabled` defaulted false and therefore shipped the backfill lane HELD,
+ which is why backfill's default is the odd one. Defaulting became REQUIRED rather than
+ merely tidy: from slice 1.4 until S0-pause an absent `held` had a legacy field to fall back
+ to, and now it has none.
+- **The backfill lane is off twice over on a fresh install** — unarmed (`enabled: false`) and
+ held. That is gate B kept as two deliberate acts, not one. Asserted through the sanitizer,
+ where a reader would actually hit it:
+ `common/jobs/autoQueuePolicy.test.ts:390` *"sanitizeAutoQueue: a lane naming no gate gets
+ the default its retired field gave it"*.
+- **A grep for `downloadsPaused` still hits, and it is not a survivor.**
+ `common/views/workers.ts:48,109` has a view-model field of that name, computed
+ `isGateHeld(i.settings, "download")`. Same word, different layer.
+- `plans/tools/phase1-numbers.ts` no longer prints the retired fields; the print loop
+ (`:142-144`) reads `isGateHeld(settings, lane)`, and `:131-137` says why.
+
+### `editor/content` is GONE — the e2e blocker in the primary checkout is closed
+
+**This supersedes both earlier entries** ("`editor/content` is a symlink out of the repo and
+it panics Turbopack in dev", and "The `editor/content` symlink still blocks e2e in the primary
+checkout"). Verified 2026-09-22 on `main` @ `766e0873`:
+
+- `ls -l editor/content` → **No such file or directory.** The symlink to
+ `/home/user/Projects/recipe-content` is not in this checkout any more.
+- `.gitignore:110` is `/editor/content` (`1e0b10a9`, rationale in the comment at `:106-109`),
+ so even if it is re-created, **`git add -A` can no longer stage it** — which was the half of
+ the hazard that carried the Turbopack panic into every worktree of a commit made that way.
+ The "add by path" discipline is no longer load-bearing for this file.
+- What remains true and is worth keeping: a fresh `git worktree` still needs a composed
+ fixture site copied into its `export/public`, or the export webServer 500s and Playwright
+ dies at the 120 s `config.webServer` timeout. That was always a separate problem.
diff --git a/plans/curated-tags.md b/plans/curated-tags.md
@@ -65,10 +65,20 @@ Tag ids: `^[a-z0-9][a-z0-9._-]*$`. Nothing in code names Eva; she is seed data (
- **Rule hits are never persisted** — only pins/suppressions. Edit a rule → rebuild index → done.
**`sites/<id>/tags.json`**: same shape. `mergeTagDefs(global, site)`: for an existing id the site entry is a
-field-wise overlay of `label/groupLabel/color/order/hidden` and may append rules; it can never delete a
+field-wise overlay of `label/groupLabel/color/order/hidden`; it can never delete a
global rule or assignment (an assignment is a fact about the video). A new id is a full site-only tag.
(Deliberately NOT `mergeAliases`' wholesale replacement.)
+> **Corrected 2026-09-22 (`dcd065e7`).** The sentence above used to say the site entry "may append
+> rules". It may not, and the code now enforces what decision 3-as-amended already said: a site layer
+> carries NO rules at all. `coerceTagDef(raw, layer)` (`common/lib/curatedTags.ts:214`) returns
+> `rules: []` whenever `layer === "site"` (`:235-238`), and both `readSiteTags` and `writeSiteTags`
+> (`common/lib/curatedTagsStore.ts:94-113`) sanitize with `{layer: "site"}` — so a hand-written site
+> rule is **dropped on read AND on write**, not merely ignored at merge time. `mergeTagDefs`
+> (`common/lib/curatedTags.ts:371`) consequently has no rule branch for it to reach. The reason is
+> the one in decision 3: a record is shared by every site carrying its channel, so a site-layer rule
+> could only tag records the other sites also publish.
+
**Pure functions** (`common/lib/curatedTags.ts`): `effectiveTagsFor(ruleHits, assignment) = (hits ∪ manual)
− suppressed`, sorted by def `order` then id; `evaluateTagRules(input, rules)` with
`input = {channelSlug, id, title, description, tags, uploadDate, captionCues?, chatCues?}`:
@@ -196,14 +206,23 @@ pnpm ops tags --json '{"op":"define","tag":{"id":"eva-topic","label":"Discussed"
Expected from the 2026-09-20 research: 7 metadata hits on `eva-collab`, ~64 streams on `eva-in-chat`, ~19 on
`eva-topic` (Legal Mindset alone). Review in `/tags` → Preview, pin survivors, suppress false positives;
then the umtool project page "Tag cited videos as eva-collab"; then `pnpm ops build-index --wait` and
-`pnpm ops build-deploy --json '{"siteId":"anilyzer"}' --wait` (NB: build-site takes `siteIds` (a list), build-deploy takes `siteId` (one) — verified 2026-09-22).
+`pnpm ops build-deploy --json '{"siteId":"anilyzer"}' --wait`.
+
+> **Corrected 2026-09-22 (`52f5c64c`).** The note here used to warn that build-site takes `siteIds`
+> and build-deploy takes `siteId`. That asymmetry is gone: **both routes take either key**, through
+> `reqSiteIds(body)` (`editor/app/api/ops/_lib.ts:179-199`), which refuses both-at-once and
+> neither-at-all, validates every id, and throws `OpsInputError` — a **400 before any job is
+> enqueued** — so a typo'd site id can no longer start a partial fan-out. `build-deploy` fans out and
+> returns `{ok, jobs, skipped}` plus `jobId` when there is exactly one job.
## Verification
- Unit: `pnpm --filter yt-dlp-transcript-common run test` (curatedTags, store, evalTree, corpus); tsc for
common/editor/umtool/mcp; `pnpm run test:scripts`; `mcp` tests.
- Index/export on the fixture and then the real corpus: `pnpm ops build-index --wait`;
- `pnpm ops build-site --json '{"siteIds":["anilyzer"]}' --wait` (note: `--wait` streams the job log and can drop
- with `fetch failed` while the in-process index build is busy — the job keeps running; poll `/api/jobs/active`);
+ `pnpm ops build-site --json '{"siteIds":["anilyzer"]}' --wait` (the hand-poll workaround this note used
+ to prescribe is gone as of `d653b504`: `--wait` POLLS `/api/jobs/<id>/log`, a poll failure is not a job
+ failure, and `followJob` falls back to `/api/jobs/active` to decide whether the job is still there —
+ see the `--wait` entry in [`FACTS.md`](FACTS.md#--wait-polls-a-log-route-and-a-dropped-poll-is-not-a-failure));
the site composes into `export/public` and exports to `export/out`, so `jq .tags export/out/tags.json`; find a legal-mindset id's page via `slugToPage` and assert `.curatedTags` on the record;
`curl -H "authorization: Bearer $WORKER_TOKEN" localhost:3001/api/ops/tags | jq '.tags[].count'`.
- Browser: `?tg=eva-collab` narrows the all-videos list and a search; chip count == `/tags.json` count;
diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -466,3 +466,14 @@ What slice 2 inherits: the shells (`buildActiveJobs.ts`, `buildWorkers.ts`,
and `lib/liveInputs.ts` are the whole editor-side surface of a view; `getAutoRunnerStatus`
and `computeLeafPending` are still called four times per poll from the status shell (not
claimed fixed; the reads are in-memory).
+
+> **Corrected 2026-09-22 (`bad9ea43`).** That last sentence described the shape slice 1 left
+> and is no longer true of `computeLeafPending`, and its parenthetical was wrong when it was
+> written: the reads were **not** in-memory. `computeLeafPending` walked the channel configs
+> and `.auto-queue/state.json` itself, so the four-lane poll read the corpus four times per
+> tick. It now takes an optional third argument —
+> `computeLeafPending(kind, paths = getPaths(), shared?: {configs?, state?})`,
+> `common/controller/autoRunner.ts:919-927` — and
+> `buildAutoQueueStatusPayload` (`editor/app/operations/status.ts`) reads configs and state
+> ONCE via `Promise.all` and hands the same pair to all four lanes. `getAutoRunnerStatus` is
+> still called per lane and is genuinely in-memory.