# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## System Instructions

Before executing any task, you MUST read and strictly adhere to the constraints defined in `specs/agent-rules.md`.

## Backlog

Use GitHub Issues for feature requests and bugs. Do not create file-based todos.

## What This Is

A Claude Code skill (open Agent Skills format) that uses Gemini's multimodal API as a proxy to analyze YouTube videos. Gemini sees video frames at 1 FPS, reads on-screen text, and hears audio simultaneously. Claude's role is triage and conversation over the resulting markdown artifacts — it never calls Gemini directly during triage.

## Commands

```bash
# Scan all configured channels (generates mind maps, optionally transcripts)
python scripts/video_intel.py scan

# Scan one channel
python scripts/video_intel.py scan --channel natebjones

# Preview what would be scanned (no API calls)
python scripts/video_intel.py scan --dry-run

# Override lookback window
python scripts/video_intel.py scan --since 30d

# Transcribe a specific video (channel auto-detected from config)
python scripts/video_intel.py transcript --url "https://www.youtube.com/watch?v=XXXXX"

# Full pipeline on a local MP4 with one Gemini upload
# (mindmap + transcript + concepts; lazy-skips already-done steps without re-uploading)
python scripts/video_intel.py process --file "./video-intel/earlyaidopters/some-talk.mp4"

# Override Gemini model (e.g., Pro for transcripts when Flash truncates)
python scripts/video_intel.py --model gemini-2.5-pro transcript --url "URL"

# Install dependencies
pip install google-genai google-api-python-client pyyaml

# Optional: vector search
pip install lancedb voyageai

# Build vector search index (requires VOYAGE_API_KEY)
python scripts/video_intel.py index

# Semantic search over transcript chunks
python scripts/video_intel.py search "permission problems" --vector

# Catch-up briefing: surface corpus videos not yet in any _briefings/ guide
python scripts/video_intel.py briefings --unseen --dry-run   # preview only
python scripts/video_intel.py briefings --unseen             # write the briefing

# Personalization: what ranks briefings + the headline digest (one shared profile)
python scripts/video_intel.py profile show                   # read-only; prints the model + both file paths
python scripts/video_intel.py profile init                   # persist profile.yaml + scaffold audience.md
```

Required env vars: `GEMINI_API_KEY`, `YOUTUBE_API_KEY`.
Optional: `VOYAGE_API_KEY` (for vector search, free at https://dash.voyageai.com/).
Optional: `VIDEO_INTEL_OUTPUT_DIR` (absolute path to the corpus; reached only when the plugin's `config.yaml` is absent — see Corpus Discovery below).

## Architecture

**Plugin, not single skill.** This repo ships as a **plugin** — a container that holds multiple independent skills — per the current Anthropic plugin format. Layout:

```text
video-intel/                          ← plugin root (git repo root)
├── .claude-plugin/plugin.json        ← plugin manifest (name, version, skill list)
├── skills/
│   ├── video-intel/SKILL.md          ← curate: scan / transcript / mindmap / process / index / dedupe / concepts / taxonomy-build
│   ├── video-intel-search/SKILL.md   ← query: search / nugget (writes only its own brief) / status / profile show (globally installable)
│   └── translate-bcs/SKILL.md        ← BCS subtitle translation
├── scripts/                          ← shared by all skills
├── prompts/                          ← shared by all skills
├── config.yaml                       ← gitignored; per-user. Copy config.yaml.example to create.
├── config.yaml.example               ← committed template
└── tests/                            ← covers shared scripts
```

Each `SKILL.md` has its own frontmatter description and is independently triggered by Claude Code. Scripts and prompts are shared at the plugin root so the skills can reuse `translate_video.py`, `gemini_common.py`, etc., without duplication. The operational-separation rule (translate_video.py does not read video-intel's config/taxonomy/meta.json) is unchanged — scripts stay independent even though they ship in the same plugin.

**When the plugin is installed**, all three skills become available to Claude. A user asking "scan my channels" triggers `video-intel` (curate); a user asking "find videos about MCP" triggers `video-intel-search` (read-only); a user asking "translate this video to Bosnian" triggers `translate-bcs`. No cross-skill invocation is required — each skill's body tells Claude which CLI commands to run, and Claude executes them from the plugin's shared scripts directory.

### Corpus Discovery

`load_config()` in `scripts/video_intel.py` resolves `output_dir` via a four-step precedence chain (see KD1 of `docs/plans/2026-04-23-001-feat-search-skill-portability-plan.md`):

1. **`SKILL_DIR/config.yaml`** — the plugin-local config, gitignored. Authored by the developer running curate workflows from a source checkout. Wins when present so a stale env var cannot silently redirect `scan` away from the canonical corpus.
2. **`$VIDEO_INTEL_OUTPUT_DIR`** — env var override for users who point a cached plugin at a different corpus. Must be an absolute path.
3. **`~/.video-intel/config.yaml`** — user-level minimal config accepting `output_dir` (required) and `vector_db_dir` (optional). Extra keys are ignored with one INFO log.
4. **Hard error** naming both the env var and the user-config path.

**Smoke-test safety (learned the hard way 2026-09-01).** Because step 1 WINS, setting `VIDEO_INTEL_OUTPUT_DIR` is NOT enough to keep a smoke test off the live corpus: a `config.yaml` in the working tree overrides it silently, and a Gate-1 smoke that runs `index` or `scan` will then rebuild the REAL index. Any smoke that must not touch live data has to **park the config** (`mv config.yaml config.yaml.parked`) so the env var wins, and **assert in the harness that it is absent** before running anything - `assert not (WT / "config.yaml").exists()`. This bites specifically in a worktree, where the gitignored `config.yaml` must be copied in for normal curate work and then removed again for the smoke.

One INFO log line per invocation names the winning source (e.g. `"Config resolved from VIDEO_INTEL_OUTPUT_DIR=/foo"`).

**Curate guard:** curate commands (`scan`, `concepts`, `dedupe`, and the `--channel` branch of `mindmap` / `transcript` / `process`) require `channels:` in the resolved config. Running them with the user-level minimal config (no channels) fails fast with an actionable message. Read-only commands (`search`, `status`, `index`, `taxonomy-build`, `profile show`) do not require `channels:`, and neither does `nugget` - it writes only an additive brief under `_briefings/nuggets/` and stays exempt from config snapshots (a channel-less config must never overwrite `config.latest.yaml`, the record of the channel list that produced the corpus). `profile init` does not require `channels:` either - it stays curate-routed because it WRITES, not because it needs channels (issue #117).

### User-level install (`video-intel-search` skill anywhere)

The read-only search skill can be made available from any project via a
user-level marketplace entry in `~/.claude/settings.json`. See
[INSTALLATION.md](INSTALLATION.md#claude-code-user-level-access-the-search-skill-from-any-project)
for the step-by-step procedure (JSON to paste, OS-specific absolute paths,
env-var vs user-config choice).

> **Critical:** the marketplace key under `extraKnownMarketplaces` and the
> suffix after `@` in `enabledPlugins` MUST both be exactly `video-intel`
> (matching `.claude-plugin/plugin.json`'s `name` field). Claude Code
> normalizes the key silently - any suffix like `-local` is stripped, the
> `enabledPlugins` entry points at a marketplace name that does not exist,
> and skills never appear in other projects. If you hit "skills not
> appearing after Claude Code restart", this is the first thing to check.

Curate operations (`scan`, `process`, `concepts`, `dedupe`, and the
`--channel` branch of `mindmap` / `transcript` / `process`) still require
the plugin repo as CWD - they read `channels:` from the plugin-local
`config.yaml` which the user-level fallback does not provide.

`profile show` is deliberately reachable from the globally installed search
skill (issue #117): "why am I seeing this" is a question asked from wherever
the user happens to be working, and the command writes nothing. `profile init`
stays curate-only. The split is by **write scope, not by topic** - both
commands concern the same two files, so a future edit that moves `init` into
the search skill "to keep personalization together" would break that skill's
no-curate-writes guarantee (nugget persists only its own brief under
`_briefings/nuggets/`). Test contract: `tests/test_skill_descriptions.py::TestPersonalizationRoutingSplit`,
`tests/test_curate_guard.py::TestProfileNeedsNoChannels`.

**Shared utilities:** `scripts/gemini_common.py` — Gemini retry logic (`get_retry_delay`), client factory with httpx timeouts (`create_client`), lazy imports (`require_gemini`, `require_youtube`). Used by both scripts; kept minimal.

**Single script:** `scripts/video_intel.py` — all logic in one file, subcommands:
- `scan` — uses YouTube Data API to discover new videos per channel, then runs (issue #54) the **transcript loop FIRST**, the mindmap loop SECOND, and the concepts loop THIRD. The mindmap loop's per-video source is decided by `resolve_mindmap_source(channel_cfg, transcript_available=...)`: `auto` (default) routes to text-from-transcript when one landed on disk in Step 1 (cheaper, faster, no 10800-frame cap), else falls back to video. Channel knob `mindmap_source: auto|transcript|video|none` overrides per-channel. Legacy `auto_mindmap: none` is preserved as a separate top-level scan-skip gate. Optionally chains transcript and concept generation — **issue #173 (scan half): the `auto_concepts` loop's candidate pre-filter (`concepts_path.exists() and not args.force`) and its `process_concepts` call (`force=args.force`) both honor `--force`**, mirroring `cmd_concepts`'s existing gate; pre-fix, `scan --force` regenerated every mindmap but silently left every existing concepts.json untouched, pairing fresh mindmaps with stale concepts on the path a bulk remediation (issue #172) is most likely to use. **Under `--force` the candidate loop is additionally bounded to `touched_prefixes`** (the same window-bounded, force-aware `new_videos` set the mindmap loop just ran against, captured right before that loop) - review round 3 caught that honoring `--force` alone turned the corpus-wide `*.meta.json` glob (unbounded by `--since` or the fetch window, and reached even on a zero-new-videos scan per the `auto_concepts` carve-out in the `if not videos:` gate) into a full-corpus concepts re-extraction on every `scan --force`. Without `--force` the pre-filter is unchanged. Corpus-wide re-extraction keeps its correct home in `concepts --channel X --force`, which is where issue #172's remediation should point. **Issue #176: the `auto_concepts` loop now accumulates each video's newly-extracted concepts into the in-memory taxonomy before the next `process_concepts` call, per ADR-0010, matching `cmd_concepts`** - so two videos in one scan (or two channels in one scan) normalize against each other's freshly-minted concepts instead of the on-disk snapshot from before the scan started. See the Code Review Guardrails entry for the shared-helper contract. Supports **selective mode**: channels with `playlists` or `keywords` in config skip the date-based uploads scan and only process matching videos. `resolve_playlist_ids()` resolves human-readable names to IDs via case-insensitive contains matching. `fetch_keyword_videos()` uses `search().list()` (100 quota units/call, capped at `KEYWORD_MAX_PAGES` pages). `fetch_selective_videos()` dispatches and deduplicates. After all primary processing and the failure summary, a full `scan` (not a `--channel X` run) renders the **headline digest** (issue #113) via `render_headline_digest`: peripheral vision over `enabled:false` + `headline_digest:true` YouTube channels, metadata-only (no Gemini, no corpus artifacts), ranked by `rank_headlines` against the compiled interest model from `load_interest_model` (read-only; never persists), seen-state in `_headlines/seen.json`. See the Code Review Guardrails entry for the five invariants.
- `transcript` — calls Gemini with `response_json=True`, parses the three-task JSON response (speech + screen_content + speakers), and merges them into a fused markdown document via `merge_transcript_json()`. Resilient to malformed JSON: tries direct parse, then `isolate_json()` cleanup, then `salvage_transcript_sections()` to recover partial content. **Task-wrapper normalization** ([issue #45](https://github.com/dzivkovi/video-intel/issues/45)): both the full-parse path (`try_parse_transcript_json`) and the salvage path (`_normalize_task_wrapper` at the top of `salvage_transcript_sections`) call `_wrapper_to_envelope_dict()` to detect Pro's `[{"task": ..., "output": [...]}, ...]` malformation and rewrite it into the flat `{"transcripts": [...], ...}` envelope. The helper only fires when the parsed top-level is a list whose items name a `_KNOWN_TASK_KEYS` task; non-wrapper inputs pass through unchanged. A scoped `_strip_cyrillic_for_structure()` strips Cyrillic-token intrusions inside `_normalize_task_wrapper` only, never globally - the global pre-strip rejected by the issue would risk corrupting verbatim foreign content. Saves raw Gemini response as `.transcript.raw.txt` sidecar on failure for forensics. One bounded retry if salvage fails. Partial transcripts are written with a visible warning block and `transcript_status: "partial"` in meta.json. Accepts either `--url` (YouTube) or `--file` (local MP4) as input. For local files, uses `upload_local_video()` via Gemini Files API (48h auto-expire); by default output lands next to the source file with the filename stem as prefix. **Channel-scoped local recovery (plan rev 4):** pass `--channel <NAME>` alongside `--file` (or drop the file under `output_dir/<channel>/` and let the parent-folder inference pick it up) to route artifacts into the canonical channel folder with the same meta.json shape as scan-generated artifacts. `resolve_local_file_identity()` picks the prefix: (1) sibling `.meta.json` wins, (2) G2 dedup against canonical scan metas by `video_id`, (3) explicit `--video-id`/`--title`/`--date` flags, (4) filename stem plus `LastWriteTime`. **Issue #186: on the flags path, BOTH `--title` and `--date` together derive the artifact prefix through the scan writer's own `video_file_prefix` (`{date}-{slug}`), so curated local ingests follow the same naming convention as scanned artifacts.** One flag alone keeps the stem: a prefix built on the mtime-fallback date would change whenever the file is copied (new mtime, new prefix, duplicate artifacts on the next run). The sibling-meta and G2 prefixes are deliberately untouched - they carry already-written artifacts (renaming existing artifacts is a manual follow-up, out of scope by the issue's own statement). `--date` is validated as strict `YYYY-MM-DD` at the PARSER (`iso_date_arg`, all three subcommands) because the flag now builds a filesystem path and a `2026/08/31` would embed a separator - the parser runs before any upload, per probe-before-you-pay; the resolver keeps warn-and-fall-back-to-stem belts for library callers and for a title that slugifies to nothing. Step 1 also adopts a stem-named meta found in the TARGET channel dir (a pre-#186 ingest from outside the corpus left one there; missing it would re-bill Gemini for a duplicate ingest), the true sibling wins when both exist, and the step-1 return's `channel_dir` is the adopted meta's own directory so artifacts can never split from their meta. **The adoption is conflict-guarded (`_adoptable_channel_stem_meta`, Codex peer-pass P1)**: a same-basename meta can belong to a DIFFERENT video, and adopting it returns at step 1 before G2's video_id matching can object - so a conflicting explicit `--video-id` OR id-shaped stem OR a conflicting stored `channel` rejects the candidate with a WARNING and resolution continues to G2/fallback; an unreadable candidate is rejected the same way (adoption is an optimization, and the wrong video's meta corrupts identity). The true sibling next to the file keeps its pre-existing adopt-with-flag-overrides contract - the guard is only for the channel-dir candidate. Known residual, accepted: an id-less local file processed once WITH flags and once WITHOUT splits across two prefixes - nothing can find the derived meta without a `video_id`; the recovery is consistent flags or `--video-id`, and the 403-recipe path is safe because id-shaped stems dedupe through G2. Reviewers: the derived prefix must come from `video_file_prefix`, never a re-derived `f"{date}-{slugify(title)}"` copy - the PR #136 checker/writer rule. Test contract: `tests/test_local_mp4_recovery.py::TestPrefixFromExplicitFlags`, `::TestDateFlagValidation`, `::TestChannelDirStemMetaAdoption`, `tests/test_cmd_process.py::TestCmdProcessFileDerivedPrefix`. Canonical `video_url` and Gemini `media_uri` stay separate fields so `file_uri` never persists to disk. Both paths support `--start`/`--end` for segment clipping (parsed by `parse_time_to_seconds()`, accepts `MM:SS`, `HH:MM:SS`, or raw seconds).
- `mindmap` — generate a mind map for a single video. Accepts `--url` (YouTube) or `--file` (local video) as input. **Issue #54: the `--url` branch consults `resolve_mindmap_source` and reads on-disk transcript text when one exists**, calling Gemini text-only via `process_mindmap(source="transcript", ...)`. Falls back to legacy mindmap-from-video (with the issue #50 fps fallback for the 10800-frame cap) when no transcript is on disk and the channel's `mindmap_source` resolves to `auto`/`video`. The `--file` path mirrors `transcript --file` and stays on the legacy mindmap-from-video path (chunking it would defeat the one-upload guarantee that motivated `process --file`); drop a gated-video MP4 under `output_dir/<channel>/` (or pass `--channel` explicitly) to route the `.mindmap.md` + `.meta.json` into the canonical channel folder using the same identity resolver as transcript. Used by the scan 403 recovery flow: `scan` on a members-only video returns 403 and the log line prints a two-command recipe (`mindmap --file ... --channel ...`, then `transcript --file ... --channel ...`).
- `concepts` — extract and normalize concepts from existing mindmaps against a growing canonical vocabulary (thesaurus). Text-only Gemini calls reading mindmap markdown, not video.
- `process` — pipeline orchestrator. Two input modes:
  - **`--url`** (YouTube): inverted ordering per issue #54 — Step 1 transcript (chunked if long, per PR #51), Step 2 mindmap-from-transcript (text-only Gemini call against the on-disk transcript), Step 3 concepts. Resolver picks mindmap source per channel; `auto` (default) uses transcript when Step 1 wrote one, else falls back to mindmap-from-video. The Step 1 transcript call is wrapped in try/except so an uncaught transcript exception still lets Step 2 run with `source="video"` fallback (preserves the "mindmap is the AI's discovery surface and must always run" invariant). The 10800-frame fps fallback is preserved on the video-fallback branch only — text input has no frame cap. **Issue #173: Step 3's `process_concepts` call passes `force=args.force`** (an isolated omission previously left `process --url --force` re-runs with stale concepts from the pre-force mindmap, silently, at exit 0). The ENTIRE step body — `mindmap_path.read_text()`, `load_taxonomy()`, and the `process_concepts` call — is wrapped in one try/except + `_record_concepts_error`, matching the `--file` path below: an uncaught exception anywhere in Step 3 has no other handler between `_cmd_process_url` and `main()`'s bare dispatch, so a preamble read that raises (a corrupt mindmap encoding, a cloud-mount `OSError`, an unparseable `taxonomy.json`) must land inside the same net as the call itself, not just wrap the call. The same fix (both the pre-filter and the `process_concepts` call) also applies to `scan`'s `auto_concepts` loop above. Test contract: `tests/test_process_force_propagation.py` (parametrized over all three steps and both `--url`/`scan` concepts sites to catch a future step or site that drops force propagation).
  - **`--file`** (local MP4): UNCHANGED. Calls `upload_local_video()` once (lazy: skipped when meta.json already records all modes completed and artifacts exist on disk), threads the `file_uri` to `process_mindmap(..., media_uri=...)` and `process_transcript(..., media_uri=...)`, then runs `process_concepts(...)` inline on the now-on-disk mindmap text. Stays on legacy mindmap-from-video because chunking would multiply the upload cost, defeating the one-upload guarantee. Partial-success semantics: mindmap persists even if transcript fails; exit 0 whenever mindmap succeeded. File-expiry fallback: if either helper returns a status matching `_is_file_expiry_error_status()`, re-upload once and retry once.

  The observability helper `log_usage_metadata()` logs token usage on every Gemini call through `process_mindmap`, `process_transcript`, `process_concepts` (via the `on_response` callback in `call_gemini`/`call_gemini_text`); `cmd_nugget`'s direct `generate_content` call at `:3356` is intentionally not instrumented. Accepts `--file` (required for the local path), `--url` (for the YouTube path), `--channel`, `--video-id`, `--title`, `--date`, `--start`/`--end`, `--force`, and `--prompt`.
- `taxonomy-build` — rebuild `taxonomy.json` by aggregating all per-video `concepts.json` files. This is a derived artifact, always rebuildable.
- `topics-build` - rebuild `topics.json` from the two topic-assertion sources: briefing front-matter `video_ids` keyed by the first folder under `_briefings/`, and per-video meta.json `topics` stamps written by `--topic`. Derived, byte-stable, always rebuildable, and it never touches `taxonomy.json`. `--dry-run` prints and writes nothing. Read surfaces: `status` (per-channel topics rollup), `search --topic <slug>` (with a query: filtered search; WITHOUT a query, issue #188: a pure listing of the topic's members from topics.json - no retrieval, no index), and `nugget --topic <slug>` (scopes synthesis retrieval to the topic's members). See the Code Review Guardrails entry for the invariants.
- `briefings --unseen` — catch-up briefings (issue #80). Selects corpus videos absent from every existing `_briefings/**/*.md` front-matter `video_ids` list (strict set difference via `load_seen_video_ids`, **never window-based** — so a video is never re-surfaced once it lands in any briefing), bounds them to a UTC date window (**unbounded by default as of issue #88** — `compute_catchup_window` returns `lower=date.min` when `--since` is absent, so a never-briefed video of any age is a candidate; `--since`/`--until` *narrow* back to a floor when wanted; the old 30-day `DEFAULT_RECENCY_DAYS` floor was removed because it hid old-but-never-briefed videos, the opposite of a catch-up's job) and caps to the top-N by relevance (`--limit`, default 30, `0` = no cap; uncapped videos stay unseen for the next run, so the cap is a rolling catch-up, not a drop). Ranks by concept/taxonomy overlap against the compiled interest model from `_briefings/profile.yaml` (`load_interest_model` then `compile_interest_model`, issue #115: inferred in memory from the scanned channel list + top `taxonomy.json` concepts when no file is persisted, and **never persisted by this command**: `profile init` is the only writer, and an existing file is **never overwritten**, so hand-edits are the retune path; tolerates a malformed hand-edit — list/scalar `interest_concepts`, bare-string `interest_domains`, string weights — without crashing). Writes `_briefings/<date>-catch-up-unseen.md` (suffixed if one already exists that day, so a same-day re-run never clobbers an earlier briefing's `video_ids`). Zero-score entries render without mindmap deep-links (their timestamps can mismatch the title). `--dry-run` prints the ranked unseen set and writes nothing (not even `profile.yaml`). The corpus walker `collect_corpus_videos` skips `.`- and `_`-prefixed dirs so `_briefings/` is never mistaken for a channel. No Gemini call and no `channels:` required — deterministic ranking only; the optional LLM-judgment layer is deferred per [the requirements doc](docs/brainstorms/2026-06-21-knowledge-gap-detection-requirements.md). **Temporal rendering (issue #88):** the primary list stays strictly relevance-ranked (recency is only a tiebreaker in `rank_unseen`, so old-but-high-relevance videos surface near the top, not buried); temporal structure lives in (a) a per-item **age badge** (`· age 3y`/`8mo`/`5d` via `_format_age` — a mechanical y/mo/d derivation, NOT a semantic "evergreen" judgment the score can't support) and (b) a secondary **"By year" appendix** (`_render_by_year_appendix`) that regroups the *same* `video_ids` newest-year-first. Do NOT put year/month headers inside the primary list — that fights the relevance sort. On the first run against a large corpus with no `profile.yaml` yet AND `total_unseen > limit`, `cmd_briefings` emits a one-line cold-start WARNING (freshly-inferred profile can overweight generic concepts; points at `profile show` / `profile init` + hand-edit) — it warns rather than reimposing a floor. **Topic subfolders under `_briefings/` (e.g. `_briefings/sales/`, or any other name) are a supported, code-free organizing convention** (2026-07-06) — `load_seen_video_ids` recurses (`rglob`, not `glob`), so briefings moved or written into a subfolder stay in the "seen" set. There is no per-topic config, flag, or naming convention baked into `briefings`; for BRIEFING SELECTION and SEEN-STATE folder names remain meaningless to the code and can change freely over time. **That claim stopped being universal in issue #146**: for TOPIC DERIVATION the first path segment under `_briefings/` IS the topic name, so renaming a topic folder renames the topic and re-slugs every membership on the next `topics-build` (`nuggets` is reserved and excluded). The two readings coexist on purpose - `load_seen_video_ids` still never looks at the name, and `topic_from_briefing_path` looks at nothing else. **PDF parity:** `scripts/briefing_pdf.py` re-implements the age badge (`_age`) and the By-year appendix locally (it deliberately does not import from `video_intel.py`); any change to the Markdown render's age/appendix behavior MUST update both, and `_age` must stay behaviourally identical to `_format_age` — test contract `tests/test_briefings_pdf.py::test_pdf_shows_age_badge_and_by_year_appendix`. Reviewers: any change that makes unseen-selection window-based instead of `video_ids` set-difference, that overwrites a hand-edited `profile.yaml`, that reverts `load_seen_video_ids` from `rglob` back to a non-recursive `glob`, that reintroduces a default recency floor (issue #88 removed it deliberately), or that inserts chronological headers into the primary relevance-ranked list, regresses the issue #80/#88 contract or silently un-sees subfoldered briefings — see `tests/test_briefings.py` (`test_load_seen_video_ids_recurses_into_topic_subfolders`, `test_compute_catchup_window_default_is_unbounded`, `test_render_unseen_briefing_by_year_appendix`).
- `profile show` / `profile init` - the personalization surface (issue #115). `show` prints the resolved interest model (source `persisted` vs `inferred`, top weighted concepts/domains, and the `output_dir`-relative paths of `_briefings/profile.yaml` + `_briefings/audience.md`) and writes **nothing**. `init` persists the inferred `profile.yaml` and scaffolds `audience.md` from `examples/audience.md`, never overwriting either file, including a partial or malformed one. No `profile edit` by decision (editing is opening the file; `show` prints the path). Neither requires `channels:`. See the "One compiled interest model" guardrail below for the invariants.
- `search` — search corpus by concept label/alias (default) or hybrid BM25+vector (`--vector`). Concept search returns matching videos with artifact paths. Hybrid search returns ranked transcript chunks by combined keyword and semantic relevance (RRF fusion). Use this FIRST when the user asks about topics — avoids reading the entire corpus. **Stage-1 query expansion (2026-04-20, [ADR-0017](docs/adr/ADR-0017-kb-layer-strategy.md)):** hybrid mode preprocesses the query through `expand_query_via_taxonomy()`, appending creator-vocabulary siblings for any canonical label or alias in `taxonomy.json` that matches the query. The expander uses a punctuation-aware boundary (handles `C++`, `.NET`, `(MCP)`, `k3s` where stdlib `\b` fails), caps sibling additions at 12 per query to limit embedding dilution, and writes its expanded string to both the BM25 FTS call and the Voyage query embed. Pass `--no-expand` to disable and run the pre-Stage-1 baseline behavior for A/B comparison. `hybrid_search()` also accepts `return_diagnostics=True` to return `(hits, expansion_record)` — the eval harness uses this to write per-query records to `tests/evals/results/<run_tag>-expansion.jsonl`. Concept-search mode (`search_corpus()`) is intentionally untouched by Stage 1. **Concept-mode RANKING is specificity-first (issue #189):** the bag score over label+all-aliases keeps its SELECTION role unchanged (exactly the same concepts match, and the exact-vs-partial video-lookup RULE is untouched; on the PARTIAL path the five concepts that fetch videos follow the NEW order, so the returned video set can change there - intended, since `[:5]` means "the best five" and #189 redefines best; the exact path's video set is order-independent and stays byte-identical), but ORDER among equals is label-phrase FIRST (2026-09-02 taste lab, operator-delegated: a concept whose own PREFERRED LABEL carries the query phrase is the concept NAMED for the query and outranks alias-only matches), then phrase-in-one-field (via the shared `_alias_boundary_pattern` - one definition of "appears as a phrase"), then best single-field term coverage, then tightness (query terms as a share of that field's own tokens), then FOCUS (ascending `_alias_count`: among otherwise-equal claims a junk-drawer concept hoarding 400+ aliases has the weakest claim on any one of them - this is what routes a contested alias like "prompt engineering", held by BOTH a 435-alias mega-concept and the focused owner, to the focused owner), with `video_count` demoted to the LAST tiebreak. The label tier is the control that killed the naive focus-only variant: a query matching a mega-concept's OWN label ("agent configuration", "token economics") still returns it first. Measured on a 19-query adjudicated set: status quo 4/19 top-1, shipped chain 17/19, with both residuals adjudicated as eval artifacts (one where the shipped answer is BETTER than the expectation, one caused by a genuine taxonomy bug - 13 duplicate-label concept pairs exist, e.g. two ids both labeled "AI Agent Configuration"; merging those is data curation, not ranking). The tie-break counts TOKEN EQUALITY with boundary punctuation stripped (`_FIELD_TOKEN_STRIP`, conservative: no `+`/`#`/`.` so "c++"/"c#"/".net" survive) - a substring numerator over a token denominator made "prompting, engineering" a perfect, maximally tight match for "prompt engineering" (Codex peer-pass P2); the SELECTION bag keeps substring semantics on purpose. The phrase regex sees the WHITESPACE-NORMALIZED query so a pasted double space cannot silently disable only the phrase bonus - count-first rewarded genericness because the bag let each query term match a DIFFERENT alias (measured live: 20 mega-concepts at a perfect bag score buried `structured_prompting` at rank 21 of 111 for `search "prompt engineering"`; post-fix ranks on the six-query measurement set: 2/1/1/1/1/1 from 21/10/3/5/5/2, with the remaining rank-1 being a concept whose alias IS the exact phrase - the taxonomy's own claim, remediable by curation, not ranking). Non-string aliases degrade to ignored (the pre-#189 bare join raised TypeError over one bad entry). Reviewers: a diff that restores `video_count` above the specificity keys, or re-derives phrase matching instead of using `_alias_boundary_pattern`, regresses this. Test contract: `tests/test_concept_rank_specificity.py`.
- `index` — build search index (vector embeddings + FTS on title/text) from all transcripts using LanceDB + Voyage AI. Required before `search --vector`. Rebuildable at any time. **`--channel X` is INCREMENTAL as of issue #183**: it embeds only that channel, deletes only that channel's rows, appends the new ones and compacts, leaving every other channel untouched. It is the only incremental primitive the tool has (plain `index` re-embeds the entire corpus every run - there is no embedding reuse), which is what makes the re-index `dedupe` and `prune-shorts` both ask for affordable. Resilient to Voyage's per-batch token cap (120,000 tokens for `voyage-4-large`): `_embed_batch` catches the typed token-cap error and recursively halves the offending batch down to a `MIN_BATCH_SIZE = 4` floor, so adding 2-3 dense transcripts no longer fails the whole rebuild (issue #44).
- `dedupe` — find and clean up title-rotation duplicates (same `video_id`, different slug). Groups meta.json files by `video_id`; for any group with >1 meta, picks canonical **quality-first, then by latest `processed` timestamp** (tie-break on `modes_completed` size, then alphabetical prefix): a meta whose transcript tripped a severe `transcript_quality_flags` entry (issue #159, reusing `transcript_quality_flags_are_severe()` from the #157/#158 quality machinery) never outranks a clean duplicate, even a much older one, so a severe-flagged rerun can no longer beat a healthy artifact on recency alone. Within one severity bucket (both clean or both severe) the ordering is unchanged. Merges loser titles into canonical's `alt_titles` list, moves artifacts for any mode only a loser has, deletes loser siblings. Dry-run by default; pass `--apply` to mutate. After `--apply`, re-run `taxonomy-build` and `index --force` (derived artifacts are not auto-rebuilt: blast radius stays predictable). Prevention is automatic: `is_processed()` consults a per-channel `{video_id: prefix}` index before falling back to slug-based existence checks, so the same `video_id` under a rotated title is recognized as already-processed. A pre-scan pass inside `cmd_scan` calls `record_alt_title_if_rotated()` to capture ongoing rotations into existing metas' `alt_titles`.
- `prune-shorts` — find and delete YouTube Shorts that polluted the corpus before the scan-time filter existed. Walks `*.meta.json` files per channel, classifies via `is_short()` (duration < 60s OR `/shorts/<id>` HEAD redirect returns 200, with one bounded retry on transient errors and fail-safe to long-form on classification ambiguity), uses cached `duration_seconds` from meta.json when present and batches `videos.list(part='contentDetails')` for legacy metas missing the field. Dry-run by default outputs `title | duration | url | artifact_count` per Short plus per-channel summary; `--apply` deletes via the explicit `PRUNE_SHORTS_DELETION_PATTERNS` allowlist (mindmap, transcript, the `.transcript.raw*.txt` and `.mindmap.raw.txt` forensic sidecars, concepts.json, meta.json) — **NOT** the whole-prefix glob `_apply_dedupe_group` uses, because translate_video.py produces `.en.srt` and `.translate-bcs.txt` siblings that share the prefix and must survive (translate-bcs is operationally separate from curate). Mirrors dedupe's blast-radius discipline: after `--apply`, re-run `taxonomy-build` and `index --force` manually.

**Scan-time `skip_shorts` filter** (per-channel flag, default `true` as of plugin v1.11.0). `cmd_scan` calls `enrich_with_durations(youtube, video_ids)` (batched 50 per `videos.list` call) for all fetched videos so meta.json carries `duration_seconds` going forward, then drops Shorts before any Gemini call. Per-channel `skip_shorts: false` opts back in for substantive-Shorts creators. On HTTP 403 quota-exceeded mid-classification, the scan aborts that channel cleanly and continues to the next instead of silently fail-safing all videos as long-form.

All commands support `--force` to regenerate existing output files. Gemini-calling commands (scan, mindmap, transcript, concepts, process) accept `--model` / `-m` at the top level to override the config.yaml model. Precedence: CLI flag > config.yaml > `DEFAULT_MODEL` constant. `MAX_OUTPUT_TOKENS = 65536` caps Gemini output (matches `translate_video.py`). Default log level is `info` (visible progress without extra flags).

**Standalone utilities.** The intelligence-layer analytics scripts (`intel_graph.py`, `lead_lag_report.py`, `lead_lag_viz.py`, `burst_report.py`, `sdsm_network.py`, `disparity_backbone.py`, `wiki_atlas.py`, `wiki_concepts.py`, `register_obsidian_vault.py`) and the operationally separate BCS translator (`scripts/translate_video.py`) each carry their own review guardrails. They live in path-scoped rules that load automatically when you touch those files: [`.claude/rules/intelligence-layer.md`](.claude/rules/intelligence-layer.md) and [`.claude/rules/translate-bcs.md`](.claude/rules/translate-bcs.md). The invariants there are binding, same as the ones below.

**Prompt templates:** `prompts/*.md` — self-contained, referenced by name (without extension) in `config.yaml`:
- `mindmap-knowledge` — thematic mind map with domain terminology + timestamps (default)
- `mindmap-light` — fast scan, 4-6 branches
- `mindmap-heavy` — comprehensive, 6-10 branches with resources/perspectives
- `transcript` — three-task decoupled prompt returning structured JSON
- `mindmap-from-transcript` — text-input mind map prompt (issue #54). Used when the resolver picks `source="transcript"`. Output structure matches `mindmap-knowledge` so downstream concepts extraction is unchanged.
- `concepts` — concept extraction + normalization against taxonomy, with `{{taxonomy}}` template slot
- `translate-bcs` — BCS subtitle translation system prompt for the **video-understanding fallback** (used by `translate_video.py` when no captions are available)
- `translate-bcs-from-srt` — BCS translation prompt for the **captions-first path** (text-in / text-out, preserves `[HH:MM:SS]` prefixes, optional `{{AUTO_GEN_NOTE}}` cleanup slot for auto-generated tracks)

**Config:** `config.yaml` — channels, output directory, model, parallelism, per-channel prompt/since overrides.

- `vector_db_dir` (optional): path for the LanceDB vector index. Defaults to `output_dir / .lancedb`. Must be on a real local filesystem — cloud-synced mounts (Google Drive File Stream, OneDrive, Dropbox) do not support the atomic file operations LanceDB needs to commit its MVCC manifests. The `index` command runs a pre-flight probe (`probe_atomic_writes`) that does a throwaway LanceDB connect + create + drop round-trip against the target path; if that round-trip fails, the command aborts with an actionable diagnostic *before* any Voyage embedding call, saving the user from paying for embeddings on a write that cannot succeed. The vector index is a derived artifact (rebuildable from transcripts via `index`), so it is safe to live in a local cache directory (e.g., `~/.cache/video-intel/lancedb`) outside a cloud-synced `output_dir`. See [ADR-0016](docs/adr/ADR-0016-vector-db-path-config.md). Related but **accepted, not code-fixed** (issue #67): on the same cloud mounts, `.meta.json` reads can be stale (read-after-write), so a `scan` can occasionally re-queue an already-done video. Unlike the index, `meta.json` is part of the portable corpus and intentionally stays on the mount; a defensive re-read would be false confidence (a stale read is indistinguishable from a genuine "not done"). The hazard is bounded to wasted re-transcription since #66 (identity always stamped) and #74 (hung transcript times out) — see `docs/troubleshooting.md` ("Cloud-mount stale meta reads").

- Per-channel `enabled: false` (added 2026-04-24) skips the channel from `scan` entirely — including explicit `scan --channel <name>` invocations — but keeps the channel addressable for `mindmap --url --channel <name>`, `transcript --url --channel <name>`, `mindmap --file` / `transcript --file` / `process --file`, and `concepts --channel <name>`. Use this for non-scannable sources: Skool communities (no YouTube API metadata), Vimeo and other platforms the URL parser does not understand, members-only YouTube that 403s from Gemini, and one-off creators whose feed is mostly off-topic. The flag is opt-in (default true) and strict — overriding it on the command line is deliberately unsupported. See [`docs/solutions/integration-issues/non-scannable-sources-enabled-flag-20260424.md`](docs/solutions/integration-issues/non-scannable-sources-enabled-flag-20260424.md) for the full pattern and `tests/test_channel_enabled_flag.py` for the contract.

**Idempotency:** `is_processed()` checks for existing output files by `{date}-{slug}.{mode}.md` naming. Re-running scan safely skips already-processed videos. All commands support `--force` to regenerate.

**Output goes to** `~/video-intel/{channel_name}/` (configurable via `output_dir`), not into this repo. Master `taxonomy.json` lives at the output root.

**Concept layer:** Per-video `concepts.json` is the source of truth. `taxonomy.json` is derived (rebuilt by `taxonomy-build`). During batch extraction, new concepts accumulate in memory so each video normalizes against concepts discovered in earlier videos. See ADR-0010.

**Search internals:** Score math, pipeline mechanics, tuning levers, and empirical observations are documented in [`docs/search-internals.md`](docs/search-internals.md). Read this before modifying search behavior.

**Testing and eval framework:** [`docs/testing.md`](docs/testing.md) is the operational reference for all three suites — unit/integration in `tests/`, the free measurability audit at `tests/evals/test_instrument.py`, and the grounded-golden-dataset retrieval eval at `tests/evals/test_search_quality.py`. As of 2026-09-02 the hybrid-search eval baseline is 1/25 on a 2,360-video / 85,854-chunk index (measured after the #195 remediation re-index of 10 channels; the instrument stayed 50/50 and the N/25 was unchanged across the re-index. Earlier reference points: 1/25 on the pre-remediation 80,297-chunk index, 0/25 on the same corpus before issue #190 fixed the instrument; not comparable to the 2026-04-19 1/25). Any PR that touches retrieval logic must re-run **`pytest tests/evals/test_search_quality.py`** and record the new N/25 in the description — run that module by name, not the whole `tests/evals/` directory, or `test_instrument.py`'s deliberate failures land in the same summary line and the N/25 is no longer derivable. The golden dataset at `tests/evals/golden_dataset.yaml` is a frozen contract per [ADR-0017](docs/adr/ADR-0017-kb-layer-strategy.md) — edits need ADR-grade justification.

**KB-layer direction:** [`ADR-0017`](docs/adr/ADR-0017-kb-layer-strategy.md) established the original staged approach (query expansion → LightRAG → LLM Wiki). Stage 1 (query expansion) shipped 2026-04-20 and did not move the 1/25 baseline. [`ADR-0018`](docs/adr/ADR-0018-nugget-cli-cross-creator-synthesis.md) (2026-04-22) shipped the `nugget` CLI for cross-creator synthesis and **deferred Stage 2 LightRAG behind three named signals** — until any of those fires, LightRAG is not the next step. The **active forward direction** is captured in [`docs/brainstorms/2026-05-28-intelligence-layer-roadmap.md`](docs/brainstorms/2026-05-28-intelligence-layer-roadmap.md): a structured intelligence layer (DuckDB 6-node / 6-edge starter schema + bidirectional Displacement/Magnet lenses + 6-tuple stance schema in `prompts/concepts.md`), justified by the observation that the user-experienced gap is *aggregate / contrastive / polarity-flipped* queries, not better retrieval. Phase 0a shipped via PR #62; Phase 1.5 spike is tracked as [#63](https://github.com/dzivkovi/video-intel/issues/63). Cognee remains rejected. The 2026-04-16 work-notes (`work/2026-04-16/03-architecture-futures-cognee-lightrag-llm-wiki.md` and `04-knowledge-layer-options-brainstorm.md`) plus the 2026-04-28 7-agent synthesis (`work/2026-04-28/03-knowledge-discovery-synthesis-paths-forward.md`) are the historical context.

## Key Design Decisions

- Gemini is a multimodal proxy, not a competing assistant. Video understanding requires vision+audio that Claude doesn't have via API.
- The transcript prompt requests structured JSON with three parallel tasks (diarization, screen content, speaker ID). `merge_transcript_json()` fuses them by timestamp sort.
- `SKILL_DIR` is resolved from the script's own path (`Path(__file__).resolve().parent.parent`), making the skill relocatable across `~/.claude/skills/`, `~/.gemini/skills/`, or `~/.agents/skills/`.
- Lazy imports (`require_gemini()`, `require_youtube()`) in `gemini_common.py` give clear error messages when dependencies are missing instead of cryptic ImportErrors.

## Packaging, Distribution, and Release

Plugin distribution model, the release checklist, and the migration note for users on the pre-plugin layout live in the `release` skill: [`.claude/skills/release/SKILL.md`](.claude/skills/release/SKILL.md).

## Development

```bash
# Install dev dependencies
pip install -e ".[dev]"

# Run unit/integration tests
pytest tests/ -v --ignore=tests/evals

# Run tests with coverage
pytest --cov=scripts --cov-report=term-missing -v --ignore=tests/evals

# Is the ruler intact? Free - no Voyage call. Run this before reading any N/25.
pytest tests/evals/test_instrument.py -v

# Run retrieval eval (requires pip install deepeval, VOYAGE_API_KEY, built LanceDB index)
# Name the module, not the directory: the audit's deliberate failures would
# otherwise share the summary line and the N/25 stops being derivable.
pytest tests/evals/test_search_quality.py -v -s

# Lint and format
ruff format .
ruff check . --fix
```

Config in `pyproject.toml`. Run ruff before declaring any task complete.

## Workflows

This project uses the [Compound Engineering plugin](https://github.com/EveryInc/compound-engineering-plugin/) for structured workflows:

- `/workflows:work` — Execute tasks with progress tracking
- `/workflows:review` — Code review with multi-agent analysis
- `/workflows:compound` — Document solved problems (produces `docs/solutions/` entries)

Session plans are stored in `plans/` (configured via `.claude/settings.json`). Plans are session artifacts — historical, not living docs.

Solved problems are recorded in `docs/solutions/` following the three-bucket rule (living / historical / decision records).

## Compounding operational recoveries (durability ladder)

When an operator (human or agent) recovers from a stuck state during a scan - a hang, a new failure mode, an undiscovered video - that recovery only compounds if it lands in a durable layer. A recovery that lives only in a chat session is re-improvised next time. After **every** such recovery, run it through one gate:

> **Could a future scan hit this same failure with no human noticing?**
> - **Yes → it must become CODE** - a runtime signal (`exception type`, status code, timeout expiry, empty output, known error substring) triggers automatic recovery. Strongest compounding; the reference implementations are the captions failover (#60), the `prompt=0` confab guard (#60), the pre-flight premiere filter (#70), and the per-transcript timeout (#74).
> - **No** (the right response needs operator judgment, or it is genuinely one-off) **→ a `docs/troubleshooting.md` row** is enough.

Sharper heuristics: *diagnosed in >10 min → at least a troubleshooting row; recurred across sessions → code.* And the decisive test for code-vs-doc: **"Would a correct automated response require knowing something only the operator knows?"** If yes, doc. If no, code. Avoid both anti-patterns - do not document (and re-improvise forever) a recovery whose signal is computable, and do not write 50 lines of code for a failure that has happened once.

`docs/troubleshooting.md` is the registry: every failure mode is a row with a **Status** of `auto (#PR)` or `manual (...)`. A `manual` row with a planned code fix is a visible debt marker; flip it to `auto` and link the PR when the fix ships. When you ship a code-layer recovery, add its CLAUDE.md review guardrail in the **same** PR so a future change cannot silently remove it.

## Code Review Guardrails

Rules for review agents (auto-selected by `/ce-code-review`) and for anyone cutting a PR. These are the non-obvious checks — the general "does it work, does it have tests" bar is assumed.

- **Bounded retries only.** The `transcript` path tries one JSON parse, then `isolate_json()`, then `salvage_transcript_sections()`, then one bounded retry if salvage fails. Do not promote this to an unbounded loop. Partial writes plus a `.transcript.raw.txt` sidecar are the designed failure mode, not something to "fix."
- **`prompt == 0` is a refusal on every Gemini-watches-the-video call: `process_transcript` (single-shot), `process_mindmap(source="video")`, and each chunk of `_run_chunked_transcript_url`.** The chunked path was the last hole (issue #123): it passed `log_usage_metadata` as its `on_response` but only LOGGED the counts, so a `prompt=0` chunk was parsed and stitched into `.transcript.md` with `transcript_status: ok` - a fabricated 50-minute window inside an otherwise real transcript, invisible because the neighbouring chunks are genuine and the coverage table showed the window as present. It now captures the counts per chunk and, on `== 0`, discards that chunk, records it in the coverage table as `FAILED (confabulation: prompt=0)`, keeps the discarded text in the existing `.transcript.raw.chunk*.txt` sidecar, and propagates `transcript_status: partial` through the existing `failed_chunks` machinery rather than inventing a new status. Reviewers: the per-chunk capture must stay INSIDE the loop (one `usage_capture` dict per chunk, not one shared across the run, or chunk N inherits chunk N-1's counts when a callback does not fire), and the guard must sit BEFORE the parse so a fabricated chunk never reaches `merge_chunked_transcripts`. Test contract: `tests/test_chunked_confab_guard.py`. Gemini reporting zero prompt tokens means it ingested no video (gated, unfetchable, or a future premiere) and generated the response from priors. Issue #60 shipped the guard on `process_transcript`; issue #119 found the same hole open on `process_mindmap(source="video")`, where it is far more dangerous: the `video` / `title` / `published` header comments are built locally after the call returns, so a fabricated mind map about a completely different video carries a correct-looking stamp, passes every downstream check, and poisons `concepts.json`, `taxonomy.json`, and the LanceDB index (three confirmed cases in the 2026-07-24 scan). Both paths capture the counts off `log_usage_metadata`'s return value through their `on_response` callback and refuse before writing. Four things reviewers must protect: (1) the comparison is `usage_capture.get("prompt") == 0`, never `not usage_capture.get("prompt")` or a truthiness test - `log_usage_metadata` returns `None` when usage metadata is unreadable, and unreadable is not proof of confabulation. Issue #125 pushed that distinction into `_coerce_token_count` so both guards inherit one interpretation, and it splits on **attribute presence**, never on which field is being read. Attribute absent (the `_MISSING` sentinel: renamed away, or the access raised `AttributeError`) means drift and yields `None`, so the guard stays quiet. Attribute present holding `None` means the wire omitted it, and protobuf-JSON omits an implicit-presence integer exactly when it is **zero**, so it yields `0` and the guard still fires. A wrong *shape* (float, bool, string, negative, list) always yields `None`. Do NOT replace this with a per-field rule: an earlier revision hard-coded "`promptTokenCount` is always sent, so its absence is drift", but the SDK declares all five counts identically `Optional[int]`, and if the serializer omits zeros then a genuine `prompt == 0` confabulation arrives as `None` and SILENTLY MUTES both guards - the exact failure they exist to prevent. The presence rule is correct whichever encoding Gemini uses, so it needs no unverified premise. Two consequences to keep: the `getattr` default must stay `_MISSING` (a `0` or `None` default erases the distinction before the helper sees it), and an unreadable `prompt` logs a WARNING that the guard could not run, because a guard that stops guarding must never do it silently. Unreadable counts render as `?` (`UNREADABLE_COUNT_DISPLAY`). Test contract: `tests/test_gemini_common.py` plus `tests/test_usage_shape_guard_parity.py`, which drives the shared `tests/usage_shapes.py` table through ALL THREE real guards (transcript single-shot, video mindmap, and each chunk of the chunked path) - without it the seam can be changed while the guard suites keep passing; (2) the mindmap guard **raises** rather than early-returning, so the function's existing `except` handler stamps identity (`video_id`, `channel`, `title`, `published`) plus `last_error` the same way every other failure does - an early return leaves the identity-less meta that issue #66 had to go back and repair; (3) the discarded text is written to a `{prefix}.mindmap.raw.txt` forensic sidecar (mirroring `.transcript.raw.txt`) and the sidecar stays in `PRUNE_SHORTS_DELETION_PATTERNS`; (4) no semantic title-vs-content judge - issue #119 rejects it as expensive, probabilistic, and circular, and every observed case is caught deterministically here. `source="transcript"` is deliberately NOT guarded: it is a text-only call where `prompt == 0` means something else entirely, and per issue #54's inversion it is not the confabulation vector. Test contract: `tests/test_mindmap_confabulation_guard.py` (video path) and `tests/test_captions_failover.py::TestConfabGuard` (transcript path).
- **Completed-livestream VODs route captions-first and never get a fallback mindmap-from-video call (issue #120).** Empirically their YouTube-URI ingestion breaks at a wholly different rate than regular uploads (10 of 22 vs 0 of 377 in the 2026-07-24 corpus sample: five hard `400 INVALID_ARGUMENT`, three confabulations, two missing mindmaps), so `prompt=0`/#119 is the backstop and this is the rule that stops paying for the call at all. Five invariants: (1) the flag comes from `fetch_preflight_status`, which asks the **existing** issue #70 `videos.list` call for one more part (`liveStreamingDetails`) - parts are free, so this must never become a second API call, and `_is_completed_livestream` requires the resource AND `liveBroadcastContent not in ("upcoming", "live")` so a scheduled premiere stays an issue #70 skip rather than becoming a "VOD"; (2) captions-first is **provenance-gated, not unconditional**: `livestream_captions_first_applies` is the single place that decides, and it must read the RAW sources because `resolve_transcript_source` collapses implicit-default and explicit `gemini` into one string. Implicit default (nobody set the key) and explicit `auto` route captions-first (`auto` delegates the ordering choice to us); an **explicit** `transcript_source: gemini` - CLI flag, or the channel dict literally containing the key, tested with `"transcript_source" in cfg` and NEVER `.get(..., "gemini")` - is honored and stays Gemini-first exactly as pre-#120, retry budget included. That operator chose multimodal on purpose (captions known-garbage, wrong language, or the on-screen content IS the content), the config contract at `resolve_transcript_source` documents it, and it is the escape hatch when the premiere heuristic misfires. `process_transcript`'s parameter is therefore named `livestream_captions_first` (the adjudicated decision) not `was_livestream` (the raw classification) - callers combine the two, and the mindmap-suppression rule below deliberately keeps using the RAW flag because it is about not wasting a fallback call, independent of the source preference. Do not reintroduce a second captions implementation, and keep the `transcript_source: youtube_captions` provenance marker; (3) a captionless VOD gets **exactly one** Gemini attempt **on the single-shot path** (`parse_retry_limit = 0` in `process_transcript`) - the parse retry exists for stochastic JSON malformation on a healthy ingest, not for a URI Gemini cannot fetch, where a second call fails identically and doubles the bill. This does NOT extend to the chunked path, which deliberately still runs its N per-chunk calls: `ihM91WWU0lE` hard-400'd single-shot on every scan and transcribed fine chunked against the same URI, so chunking is the one Gemini retry shape the evidence supports; (4) `should_skip_video_mindmap_for_livestream` blocks the mindmap-from-video fallback **only** when all three of livestream + `resolved_source == "video"` + a transcript status that actually `startswith("error")` hold - `transcript_status is None` (transcript never attempted) must keep today's routing, because a failed attempt is the only evidence the URI is broken; (5) **non-premiered, non-livestreamed** uploads keep byte-identical routing, which is the highest regression risk in the change - the default `was_livestream=False` must stay, and captions must stay strictly AFTER Gemini for them. State the claim exactly that narrowly: YouTube attaches `liveStreamingDetails` to an aired **premiere** of an ordinary upload identically to a genuine livestream and exposes no field that separates the two, so a premiere is flagged and routed captions-first as well. Measured on the live watchlist at 6 of 249 recent videos with no confirmed false positive, but a creator who premieres every upload would silently slide to speech-only transcripts. That is why the scan logs one line PER flagged video (id + title), not an aggregate count: the misfire has to be auditable from the scan log. Do not "fix" this by guessing at duration heuristics or `actualStartTime` deltas - if it becomes a real problem the answer is a per-channel opt-out, and the log is what will tell us. The scan plumbs the flag onto the per-video dict in the pre-flight pass so no loop pays an extra lookup; the manual `--url` paths call `_lookup_was_livestream` (1 quota unit, same `fetch_preflight_status` helper, fail-safe False when the lookup fails). Reviewers: grep for `was_livestream` and `should_skip_video_mindmap_for_livestream` in any scan/transcript/mindmap diff. Test contract: `tests/test_livestream_routing.py`.
- **Writer-side meta reads go through `_read_meta_best_effort`, never a bare `json.loads`, and the two failure classes stay apart (issue #124).** Every path that merges into an existing `meta.json` sits inside, or feeds, an exception handler whose job is to RECORD a failure; a read raising from inside that handler masks the error it was preserving. Verified on the real handler: a corrupt meta plus a failing Gemini call used to raise `JSONDecodeError`, `UnicodeDecodeError`, or `AttributeError` and the Gemini error was lost; all five corruption shapes now record it. Four things reviewers must protect:
  1. **The catch is `(ValueError, OSError)`, never `(json.JSONDecodeError, OSError)`.** `UnicodeDecodeError` subclasses `ValueError`, NOT `OSError`, and a write torn mid-multibyte-character is the normal shape of a truncated write on a corpus with Cyrillic/BCS titles. This file has been bitten by exactly that twice before (`_load_video_id_index`, `load_interest_model`); the first attempt at this very fix shipped the narrow tuple and left the headline case open.
  2. **Unusable CONTENT and a failed READ are different, and conflating them trades one bug for a worse one.** Content we read but cannot use is quarantined to a `.meta.corrupt.json` sidecar and replaced. An `OSError` means the bytes may be perfectly intact, so `update_meta` (the shared SUCCESS-path writer) passes `raise_on_os_error=True` and propagates: overwriting there would destroy `alt_titles` (title-rotation history exists nowhere else) and `skip_modes`/`skip_reason` (the operator's deliberate stage suppression, issue #42). Error-path callers pass `False`, because there the alternative is destroying the error being recorded. A diff that defaults the keyword, or hands `True` to an error handler, breaks one half or the other.
  3. **Read bytes, then decode+parse.** `read_text()` raises `UnicodeDecodeError` from the call that is supposed to only be able to fail at the I/O layer, folding the two classes back together.
  4. **Every writer still stamps identity.** A `{}` read means the fields dict is the whole file, so a writer supplying only `{"processed": ...}` would leave an identity-less meta that `_load_video_id_index` skips - re-queueing the video for a full re-transcribe (issue #66). The concepts writer and `cmd_mark_skip` were fixed for this in the same PR.
  5. **The quarantine saves the bytes the helper ALREADY read, never a re-read**, and never overwrites an existing sidecar. Re-reading opens a race where a concurrent healthy writer's fresh bytes get quarantined and then overwritten by the caller - the recovery mechanism becoming the data loss it exists to prevent.
  Call sites: `update_meta`, `process_mindmap`'s `except` handler, `process_transcript`'s parse-failure writer, `_record_transcript_error`, `cmd_process --file`'s upload-failure handler and both of its identity reads, `cmd_mindmap --file`, `cmd_transcript --file`, `cmd_mark_skip`, and the mindmap `transcript_status` provenance read. Reviewers: a new `json.loads(meta_path.read_text(...))` anywhere on a writer or error path needs pushback. Reader-side consumers that WANT strictness are deliberately excluded - `_load_video_id_index` must not invent identity from a damaged file. Test contract: `tests/test_meta_best_effort.py`.
- **`cmd_transcript` and `_cmd_process_url` resolve the channel dict through `channel_config_by_name`, and every config-reading decision on one invocation uses the SAME dict (issue #127).** `cmd_transcript` used to hand `resolve_transcript_source` a literal `{}` while `_cmd_process_url` passed the real channel config, so two adjacent manual paths disagreed about the same `config.yaml`. Worse, within `cmd_transcript` the source resolver saw `{}` while the #120 livestream router also saw `{}` - meaning a channel-level explicit `transcript_source: gemini` could not be honored on a VOD, which is exactly the escape hatch invariant (2) above promises. Measured on the live watchlist: all 6 channels configured `transcript_source: auto` silently resolved to `gemini` on a manual `transcript --url`, so the operator lost the captions failover they had configured. Three things to protect: (1) the source is resolved AFTER channel resolution, not before - the CLI-only binding at the top of `cmd_transcript` is a placeholder for the `--file` branch and any early return, never the final answer for `--url`; (2) an unresolvable or `_standalone` channel must yield a literal `{}`, never a synthesized default, because `livestream_captions_first_applies` distinguishes an ABSENT key from a present one and a faked default would flip VOD routing; (3) the scope is `--url` **without** `--start`/`--end` - a local `--file` and a manually clipped segment are both explicit, targeted instructions, so they keep the CLI-only answer. The segment case is not cosmetic: under `transcript_source: auto` every Gemini failure branch falls back to `_try_captions_transcript`, whose only overwrite guard is `exists() and not force`, so the documented high-res segment recovery (`transcript --url --force --start .. --end .. --media-resolution high`) would replace a good full multimodal transcript with a segment-clipped, speech-only captions one. Pre-#127 that was unreachable because the source here was always `gemini`; honoring channel config made it reachable, and exempting segments closes it. A diff that hoists `channel_config_by_name` above the `if args.file:` branch, or drops the `manual_segment_requested` check, reopens both. Precedence is unchanged throughout: CLI flag > channel config > `"gemini"`. Test contract: `tests/test_manual_url_transcript_source.py`.
- **`scan` chunks too, and an output-cap truncation has its own status (issue #128).** Density, not duration, predicts an OUTPUT-cap truncation: a dense 42-minute keynote emits far more structured JSON per minute than a conversational interview, so it sailed under every guard (2h threshold, 50-minute chunk trigger, ~1M input cap) and truncated at `MAX_OUTPUT_TOKENS` with **no API error** - Gemini returns 200, the JSON stops mid-object, salvage recovers what it can, and the run exits 0. Two halves, and the second is the one that compounds:
  1. **Prevention.** `resolve_chunk_minutes(channel_cfg, config, cli_override)` follows the same precedence as every other knob here (CLI > per-channel > top-level > default), and `_scan_transcribe_one` routes a long video through `_run_chunked_transcript_url` instead of always calling `process_transcript` single-shot. Three cases deliberately keep the single-shot path and must stay that way: an **unknown duration** (never guess a chunk layout from a duration we could not parse), **`yt-captions`** (the track comes back whole), and a **livestream VOD routed captions-first** - `process_transcript` owns that ordering per issue #120, and diverting it into the chunker would spend N Gemini calls against a URI not yet known to be fetchable. Note the default-behavior change: any scanned video over `chunk_minutes` now takes N calls where it took one, which is the point (single-shot on hour-long videos is the documented irrecoverable malformed-JSON mode) but is worth knowing.
  2. **Detection.** `hit_output_cap(candidates, finish_reason, max_output_tokens=...)` names the failure as `transcript_status: "truncated_output"` instead of the generic `"partial"`, and persists `transcript_output_tokens` + `transcript_finish_reason`. Without that split a salvage-from-malformed-JSON and a salvage-from-truncation are indistinguishable in `meta.json`, so there is no way to sweep the corpus for the videos a chunked re-run would actually fix - and a blanket "re-run everything marked partial" sweep would waste money, because the salvaged text is often nearly complete (the observed case covered 00:08-41:41 of a 42:08 video). **Both** signals are needed: `finish_reason == MAX_TOKENS` is authoritative but not always exposed, and Gemini can reach MAX_TOKENS with *thinking* consuming the budget, leaving `candidates_token_count` absent entirely. Reviewers: `OUTPUT_CAP_RATIO` (0.98) sits three orders of magnitude above the observed healthy per-chunk range (1,028-10,742) and just under the confirmed truncation (65,522 of 65,536); do not lower it toward the healthy band. And do **not** extend the claim: this detector does NOT catch the other early-stop shape seen in the same sessions, where Gemini collapses a window into one monolithic block and stops with candidates nowhere near the cap. That has no output-budget signature and needs its own detector; `test_does_not_claim_the_monolithic_early_stop_shape` exists to keep the scope honest. **That shape now HAS a claimant (issue #157):** `assess_transcript_artifact`'s monolithic-collapse detection (`MONOLITHIC_MAX_ENTRIES` / `DENSITY_SEVERE_PER_MIN`) catches it deterministically by counting dialogue entries against the known window, independent of `finish_reason` or token counts - see the quality-assessor guardrail entry below. Test contract: `tests/test_output_cap_truncation.py`.
- **A dropped socket is retried once; a refusal never is; and a step that produced nothing exits 3 (issue #129).** Two defects from one 36-video ingest, and they need opposite instincts. Take them in order.

  **Retry.** `Server disconnected without sending a response.` is `httpx.RemoteProtocolError`, and `google.genai` does not wrap httpx exceptions (there is no `except httpx...` anywhere in `_api_client`), so it arrived at `get_retry_delay` as a non-`APIError` and returned `None` - one dropped socket killed a whole pipeline step, 7 times across three stages under 4-way concurrency. `is_transient_transport_error` now classifies it. Five things reviewers must protect: (1) **the `APIError` branch returns from inside itself.** That early return is the only thing keeping `PERMISSION_DENIED` (403) and `INVALID_ARGUMENT` (400) failing fast - a diff that lets an `APIError` fall through to the transport check re-bills every gated video twice. (2) The net is `httpx.TransportError` **minus three members**. `LocalProtocolError` and `UnsupportedProtocol` are client-side faults (we built a bad request / bad scheme) that fail identically on retry. **`ReadTimeout` is excluded on COST**, and this is the exclusion a future reader will want to undo: `create_client`'s `read` timeout is **1200s**, 40x the other three (connect/write/pool are 30s), so retrying it risks a second 20-minute wait. Transcript calls are safe either way (`_run_with_timeout`'s 600s cap fires first and raises `TranscriptTimeout`, not an httpx error), but mindmap-from-video and concepts have **no outer deadline**, so one unreachable response could stall a sequential scan stage ~40 minutes - strictly worse than the pre-#129 single 20-minute failure. Every failure observed in #129 was `RemoteProtocolError`; nothing in the evidence argues for retrying read timeouts. Re-admitting it requires first giving mindmap/concepts a total wall-clock deadline. `HTTPStatusError` is deliberately outside the net - a status carrying a server verdict is the `APIError` branch's business. (3) **`MAX_RETRIES_TRANSPORT = 1`, and the backoff is 2s not 60s.** Transcript calls run inside `_run_with_timeout`'s 600s cap (issue #74) which wraps the whole `call_gemini` invocation *including the sleep*, so the server ladder's 60-480s waits would eat the budget the retried call needs. This is a per-CALL budget with no run-level cap on top, on purpose: each chunk is an independent unit of work, and a shared budget would let chunk 1's bad luck starve chunk 8. (4) **`max_retries_transport` defaults to `0`.** `translate_video.py` shares this helper and is operationally separate; it must not inherit a retry policy as a side effect of a video-intel ticket. video-intel's three call sites (`call_gemini`, `call_gemini_text`, `cmd_nugget`) opt in explicitly. Enabling it for the translator is a one-argument change and should be its own diff with its own smoke. (5) The `prompt == 0` confabulation guards are **structurally** out of reach here - they read usage metadata off a response that already arrived, so they run after the retry loop returned. Nothing they raise is an httpx error. Do not add a "don't retry confabulations" special case; there is nothing to special-case.

  **Exit code.** `process` was documented as "exit 0 whenever the mindmap succeeded", so a concepts step could report an error, write no `.concepts.json`, and exit 0 - and because `is_processed()` never looks at concepts, that video was never re-queued and simply never reached `taxonomy.json` or the search index. 4 of 36 videos were incomplete behind clean exits. It is tri-state now: `0` = every requested step left a usable artifact, `EXIT_PARTIAL` (3) = the run finished but a requested step produced nothing, `1` = hard failure that stopped the run. **This is a deliberate, documented flip of the old contract, not a silent one** - the PRESERVATION half is unchanged (a transcript failure still never rolls back the mindmap) and only the REPORTING half moved, because the exit code was the one surface a batch driver could see. Four invariants: (a) `missing_pipeline_artifacts` requires artifact presence **AND** a non-`error` status, both halves. Presence alone misses a stale artifact surviving a failed `--force` regeneration; status alone would flag every salvage. (b) The degraded-but-real statuses (`partial`, `truncated_output`, `thin`) are **not** gaps - they are designed partial success with a genuine artifact, and treating them as failures turns every recovered transcript into a false alarm. **Issue #157 amends this narrowly, not by reverting it:** a `transcript` step also carries an optional `"quality_severe"` key (`.get(..., False)` - deliberately NOT the same required-access rule as `"requested"`, since every OTHER step never sets it and `False` is the correct answer for all of them), and `missing_pipeline_artifacts` counts the step as a gap when that key is `True`, regardless of the status string. A severe transcript quality flag (monolithic collapse, a severe blind gap, a severe backward jump - see the quality-assessor entry below) sets `transcript_status: "partial"`, which is one of the very statuses invariant (b) just called "not a gap" - the amendment is that `"partial"` alone is no longer sufficient evidence of health; the `quality_severe` key is what the writer computed and the checker independently re-reads from the SAME on-disk `transcript_quality_flags` field. Mild-only flags never set this key `True` and stay pure exit 0, matching (b) exactly. Test contract: `tests/test_transcript_quality_guard.py::TestExitCodeSevereVsMild`, `TestWriterStatusLiteralsParametrized`. (c) A deliberate skip must never become a gap, and the two orchestrators achieve that **two different ways** - state this precisely, because a guardrail that describes only one of them will be "corrected" into breaking the other. On `--file`, the step IS in `steps` with `requested=False` (`skip_modes`, `mindmap_source: none`). On `--url`, the skip branches return early and simply never append the step at all, which is why the mindmap and concepts entries there can be hardcoded `requested: True` - by the time either is appended, the skip branches (`mindmap_source: none`, the issue #120 livestream mindmap suppression, concepts on `_standalone`) have already returned. Both routes are correct; what matters is that no reachable path appends a skipped step with `requested=True`. A failure-driven omission is NOT a deliberate skip and must not be relabelled as one. (d) `_record_concepts_error` must keep stamping identity (issue #66 - an identity-less meta re-queues a full re-transcribe, so recording a cheap failure must not cost an expensive re-run), must keep reading through `_read_meta_best_effort(..., raise_on_os_error=False)` (issue #124 - a bare `json.loads` on an error path raises from inside the handler that exists to preserve the error), and must **not** route through `update_meta`, which is the SUCCESS writer and would clear `last_error` and mark the mode complete. Test contract: `tests/test_transport_retry_and_partial_exit.py`. Gate-1 A/B harnesses that reproduce both defects against a real dropped socket (no mocks): `docs/plans/gate1-evidence/issue-129-drop-server-smoke.py` and `issue-129-exit-code-smoke.py`.
- **A test that asserts on an exit code must assert the CODE, not merely that `SystemExit` was raised (issue #185).** `pytest.raises(SystemExit)` is satisfied by `SystemExit(0)` exactly as happily as by `SystemExit(1)`, so it is not coverage of an exit code - it is coverage of "the function stopped". Issue #185 reported `process --file <oversized>` logging an ERROR and exiting 0, the false-success shape the tri-state exit contract from #129 exists to prevent. Executed against the real CLI on a 1.5 GB file, **both `process --file` and `transcript --file` already exit 1**, and `git log -L` shows the guard has carried `sys.exit(1)` since PR #32 - the reported behaviour does not reproduce, on any of the four adjacent produced-nothing paths either (oversized-plus-segment, a non-video file, a directory, an empty file: all exit 1 through the upload's own failure). What DID exist was the blind spot that would let it regress unnoticed: the only coverage was a bare `pytest.raises(SystemExit)` on `cmd_transcript`, and `cmd_process` had none at all. Three things to keep: the assertion is `exc.value.code == 1`; both commands are covered, because the two guards are separate code with separate messages; and the guard's ORDERING is asserted by proving `upload_local_video` was never called - an exit-code-only assertion passes whether the guard runs before or after a multi-minute upload, which is the repo's standing probe-before-you-pay rule. The `--start`/`--end` bypass is deliberate and has its own test, so a later "tightening" of the guard cannot quietly remove the only way to process a large local file. Test contract: `tests/test_file_size_guard_exit.py`.
- **A verifier must use the WRITER's path, never re-derive its own - and the test that proves it cannot be a stub test.** Whenever code is added that *checks whether an artifact exists* (an exit-code gate, an idempotency probe, a completeness sweep), it has to obtain the path from the same rule the writer used. PR #136 shipped one such check and three separate defects fell out of it, all the same mistake: the exit check built `channel_dir / f"{prefix}.concepts.json"` while `process_concepts` hardcodes `output_dir / channel_name`; and `_cmd_process_url` looked under the `video_id`-indexed prefix while `process_mindmap`, given no explicit `prefix=`, wrote under the title-computed one. Each produced a **permanent false failure on a fully successful run**, where the logged recovery ("re-run to fill the gap") can never fill it - the worst possible outcome for a guard whose entire value is being believed, because it trains the operator to ignore the signal. The two sides drift for real reasons: identity resolvers, title rotation, and `channel_dir_override` all move the writer's destination without touching the checker. So: pass an explicit `prefix=` / `channel_dir_override=` to the writer and reuse that same value in the check, or have the writer return the path it used. **The second half is not optional.** Every stub-based test hands the artifact path to the stub, so writer and checker agree by construction and cannot disagree - PR #136's suite was green at 1537 passing with all three defects live, and both reviewers who found them did so by EXECUTING a repro, not by reading the diff. A test only covers this class if it derives the writer's real destination and the checker's expectation **independently and then compares**; the shape to copy is `tests/test_transport_retry_and_partial_exit.py::TestCheckerAndWriterAgreeOnPaths`. Reviewers: any new `path.exists()` check on a per-video artifact whose path is rebuilt from parts, rather than taken from the writer, needs pushback.
- **Concept accumulation during batch extraction is ONE helper, `accumulate_concepts_into_taxonomy`, never two copies (issue #176; ADR-0010).** ADR-0010 requires that during batch extraction each video's newly-discovered concepts merge into the in-memory `taxonomy` dict so the next video normalizes against them, not against the on-disk snapshot from before the run started. `cmd_concepts` already did this; `cmd_scan`'s `auto_concepts` loop did not, so two videos scanned together could mint separate labels for the same concept where one `cmd_concepts` run would have unified them. The fix extracted the inline block `cmd_concepts` already had into a shared module-level helper and called it from both loops - do not let a future edit reintroduce a second inline copy in either loop; that duplication is exactly how the two paths drifted apart the first time. Five things reviewers must protect: (1) **the concepts path passed to the helper comes from the prefix `process_concepts` RETURNED** (`out_prefix` in the scan loop, `prefix` in `cmd_concepts`), joined as `output_dir / ch_name / f"{returned_prefix}.concepts.json"` - the same PR #136 rule as the bullet above (a verifier/accumulator must use the writer's path, never a separately re-derived one), because `process_concepts` computes its own destination from `channel_dir = output_dir / channel_name` and a prefix mismatch (title rotation, a locally-recovered file's stem prefix) makes the accumulator read the wrong file or nothing. (2) **The read+parse inside the helper is guarded by `except (ValueError, OSError)`, never `except (json.JSONDecodeError, OSError)`.** `UnicodeDecodeError` subclasses `ValueError`, not `OSError`, and a write torn mid-multibyte-character (Cyrillic/BCS titles) is the normal shape of a truncated write on this corpus - this file has been bitten by the narrower tuple twice before (issue #124). A malformed or unreadable `concepts.json` must degrade (skip, warn once, return 0), never raise, because this helper runs inside a loop that has already paid for a Gemini call - the same #124/#161/#171 paid-loop family. (3) **`cmd_scan`'s per-channel `taxonomy = load_taxonomy(output_dir)` became a LAZY, single load for the whole scan** (`scan_taxonomy = None` bound above the `for ch in channels:` loop, populated on the first auto_concepts channel that needs it), matching `cmd_concepts`'s existing single load. Reloading per channel was the identical drift one scope wider: channel 2 would re-read the pre-scan on-disk snapshot and never see channel 1's concepts minted earlier in the SAME scan. Lazy, not hoisted-unconditional, so a scan with no auto_concepts channel pays no file read. (4) **Accumulation is skipped only when the returned status starts with `"error"`; a `"skipped (exists)"` status still accumulates.** The on-disk file a `"skipped (exists)"` status points at is real, current vocabulary the next video should see - special-casing it away would silently narrow what accumulates for no reason tied to the ADR. (5) **`cmd_concepts`'s behavior is unchanged by the extraction** - it never gated on status before (an `"error"` status there never wrote a concepts.json in the first place, so the helper's own `concepts_path.exists()` check already reproduces the old no-op), so do not add a status check to the `cmd_concepts` call site as part of a future "consistency" pass; the two call sites are deliberately NOT identical in this one respect. Test contract: `tests/test_concepts_accumulation.py` (unit tests for the helper's robustness guards, plus caller-level tests driving the real `cmd_scan` and `cmd_concepts` that assert on accumulated taxonomy CONTENT, not call count - a stub-based test where the stub and the assertion agree by design would not have caught the original bug). **The read guard is THREE checks, not one, and the middle one was missed on the first cut (caught by the Codex peer pass).** `except (ValueError, OSError)` covers a failed read or an unparseable file, but a parse that SUCCEEDS can still hand back a list, a string, a number or `None` - and `.get` on any of them raises `AttributeError`, escaping the guard immediately above it inside a loop that has already paid for a Gemini call. Verified: `[]`, `"hello"`, `42` and `null` all crashed before the `isinstance(data, dict)` check was added. So the order is: catch the read/parse, then check the TOP LEVEL is a dict, then check `concepts` is a list, then check each entry is a dict. A diff that drops the middle check reopens exactly the issue #161/#171 shape this family exists to close. Test contract: `tests/test_concepts_accumulation.py::TestAccumulateConceptsIntoTaxonomy::test_a_successful_parse_with_a_non_object_top_level_does_not_crash` (parametrized over all six shapes, and falsified by neutering only that one guard - note the identical line appears three times in the file, so a falsification harness that patches the first match proves nothing). **A fourth check guards the dict the helper MUTATES, not just the file it reads**, and omitting it would have ADDED a crash path rather than closing one: `taxonomy.setdefault("concepts", {})[cid] = ...` raises `TypeError` when `taxonomy["concepts"]` is a list or a string, and pre-#176 `cmd_scan` never touched that dict at all (`process_concepts` only `json.dumps` it, which tolerates any shape). A MISSING `concepts` key is explicitly NOT corrupt - `setdefault` creates it - so a diff that widens the guard to reject an absent key silently disables accumulation on every fresh taxonomy. Caught by the executing adversarial pass, which reproduced it as a real `cmd_scan` crash after one paid call. Test contract: `::test_a_shape_corrupt_taxonomy_concepts_does_not_crash_the_paid_loop` and `::test_a_taxonomy_with_no_concepts_key_still_accumulates`.
- **A healthy-shaped transcript can still be silently corrupt, and `assess_transcript_artifact` is the one place that judges it (issue #157).** A 2,049-file corpus forensics sweep (2026-08-29, seed case `uU5Gv2h8-9g`) found three distinct shapes that every existing guard (the confab guards, the output-cap detector, the old `_assess_chunk_coverage` span ratio) let straight through with `transcript_status: ok`/`complete`: **monolithic collapse** (<=3 dialogue entries for the whole video - 25.3% of 5-30min videos under `gemini-3-flash-preview` vs 3.4% under `gemini-3.7-flash`, Fisher exact p=0.0038, duration-stratified), **blind gaps** (>=10min contiguous dialogue hole - a LONG-video problem chunking does NOT fix: 12/44 chunked 60min+ corpus videos had >=10min holes with every chunk `ok` and file status `ok`, because the hole spans a chunk boundary or hides inside a chunk that individually scored fine under the old `max(ts)-min(ts) >= 50%` metric), and **clock slip** (the seed case: one response's timestamps jumped backward ~20 minutes mid-call; `merge_transcript_json()` sorts by timestamp, so the real tail was interleaved into the middle rather than lost, but the only evidence is in RAW, pre-sort emission order). Ten invariants:
  1. **Two-tier severity, and the boundary is deliberate.** SEVERE = monolithic (`MONOLITHIC_MAX_ENTRIES` <= 3 entries, OR `density_per_min < DENSITY_SEVERE_PER_MIN` (0.1) - both gated on the assessed window being `> 300` seconds, so a genuinely short clip or a `duration_seconds=None` caller can never manufacture a severe verdict on entry count alone), a LEADING or INTERNAL blind gap `>= BLIND_GAP_SEVERE_SECONDS` (600s), or a backward jump `>= BACKWARD_JUMP_SEVERE_SECONDS` (600s). MILD (label-only, never changes status or exit code) = density `< DENSITY_MILD_PER_MIN` (0.25), a backward jump `>= BACKWARD_JUMP_MILD_SECONDS` (60s), or a **TRAILING** blind gap `>= 600s` on an otherwise-healthy body. The trailing/leading-internal split is the one asymmetry in the whole design: a long silent outro, credits roll, or Q&A cut short is common and must never false-alarm severe, while the same magnitude gap at the start or in the middle is real evidence of lost content. Reviewers: a diff that treats all three gap kinds identically collapses this distinction and will false-alarm on healthy videos with long outros.
  2. **Primary trigger is max blind gap, not coverage.** `last_dialogue_fraction` (the renamed old "coverage" concept: last stamp / known duration) is demoted to TELEMETRY only - persisted, never a trigger - because the 12 worst real blind-gap cases all showed coverage `>= 0.99`. Do not resurrect a coverage-ratio trigger; it is precisely what missed this failure class the first time.
  3. **Monolithic density is entries-per-KNOWN-window-minute, never entries-per-COVERED-minute.** A true collapse has ~0 covered span, which would trivially pass a covered-span-relative check (this was the old `_assess_chunk_coverage` bug: two entries at the very start and very end of a window score ~100% "span" despite an empty middle). `assess_transcript_artifact`'s `window` parameter (an explicit `(start, end)`, or `None` meaning "the whole known video", i.e. `(0, duration_seconds)`) is what the density/gap math is always measured against - not the entries' own timestamps.
  4. **Backward-jump detection reads entries in the ORDER GIVEN, never a sorted copy - and per-chunk callers must pass RAW, pre-classification order.** `_run_chunked_transcript_url`'s sort (needed for correct rendering) and `merge_chunked_transcripts`'s `_classify_and_offset_timestamp` (needed to reconcile Gemini's inconsistent absolute-vs-chunk-relative timestamps) both destroy this evidence, so `_assess_chunk_coverage` computes the raw backward-jump value from a SEPARATE, unclassified pass (`assess_transcript_artifact(transcripts, None, window=None)`, which - because `window` is `None` and `duration_seconds` is `None` - skips gap/density entirely and reports only entry count and the raw backward jump) and only relativizes a SECOND copy (via `_relativize_chunk_entries`, which reuses `_classify_and_offset_timestamp` rather than a second, potentially-divergent classifier) for the window-based gap/density/monolithic checks. The chunked writer ALSO removed the old post-sort "monotonicity check" at the merge site - it was DEAD CODE (the sort immediately before it ordered by the identical key the check then tested, so `secs < last_secs` was unsatisfiable) whose log line falsely claimed `transcript_status: partial` without the code ever reading its own `monotonicity_warnings`. Reviewers: any diff that computes backward-jump from a sorted or classified copy, or that reintroduces a post-sort monotonicity check, regresses this.
  5. **Integration happens on EVERY Gemini transcript meta write, healthy or not** - the chunked writer (whole-stitched-transcript call after merge+sort, unioned with every chunk's own severe/mild flags) and BOTH single-shot writer branches (the "complete" full-parse path AND the salvage path). Persisted fields: `transcript_quality_flags` (sorted list of flag names, the union other consumers re-derive severity from - see `transcript_quality_flags_are_severe`), `transcript_max_blind_gap_seconds`, `transcript_blind_gap_at_seconds`, `transcript_last_dialogue_fraction`, `transcript_dialogue_entries`. **The salvage path persists these fields but deliberately never changes `transcript_status`** - a salvage is already non-healthy for a more specific, sweepable reason (`TRANSCRIPT_STATUS_TRUNCATED` vs generic `partial`, issue #128), and overriding it down to the generic value would destroy that distinction; a healthy-density salvage (the documented "95% salvage" case: content covered 00:08-41:41 of a 42:08 video) must also never pick up a severe flag on top, which the assessor's design already guarantees (small edge gaps, good density) rather than requiring a special case.
  6. **No new `transcript_status` literal for a healthy-parse call that turns out severe** - it is demoted from `"complete"` to `"partial"` (single-shot) or from `"ok"` to `"partial"` (chunked), reusing the existing literal per the design decision "keep the writer-literal union small". The single-shot writer's returned STATUS STRING does get a new value, `"partial (quality guard)"` (distinct from `"done"`), so a caller inspecting the return value (not just the persisted meta) can tell a quality-guard demotion apart from a clean success; `missing_pipeline_artifacts`'s reader-side union treats it exactly like every other non-`error` degraded-but-real literal (invariant 8 below) - the `quality_severe` key is what actually flags it, not the string.
  7. **No auto-repair, no auto-retry, no captions-failover trigger.** A quality-severe status never starts with `"error"`, so the #120 livestream captions-first suppression and the `transcript_source: auto` captions failover (which key on that prefix) stay completely untouched by this feature. Remediation is the existing explicit `--force` recovery, per the remediate-on-demand convention.
  8. **Exit-code integration is additive, not a status-string change** (see the #129(b) amendment above): `missing_pipeline_artifacts` accepts an OPTIONAL, `.get(..., False)`-read `"quality_severe"` key on a step dict, set ONLY by the transcript step in `_cmd_process_url` and `cmd_process --file`, read from the SAME `meta_path` the writer just used (never re-derived - the PR #136 checker/writer-path-drift class) via `_transcript_quality_severe_from_meta`. Mild-only flags never set it `True`.
  9. **Containment:** see the `resolve_mindmap_source` guardrail entry above for the full `transcript_severe` amendment - `auto` treats a severe transcript as unavailable and falls back to `"video"`; explicit `mindmap_source: transcript` is honored regardless.
  10. **Runt-fold and the chunk_minutes default both changed, and they fix DIFFERENT halves of the seed case.** `RUNT_FOLD_MAX_SECONDS` (120s, an ABSOLUTE floor) replaced a 20% RATIO (`< chunk_seconds * 0.2`) that WIDENED exposure as `chunk_minutes` grew - a 90-second tail (3% of a 3000s/50-minute chunk) folded well under that 20% floor, silently turning a just-chunked video back into an effectively single-shot 51.5-minute call (the seed's exact path). `TRANSCRIPT_CHUNK_MINUTES_DEFAULT` dropped from 50 to 30 - THIS, not the fold floor, is what actually fixes the seed shape: a 90s runt still folds under a 120s ABSOLUTE floor regardless of chunk size, but at a 30-minute chunk size the seed's 3090s duration builds two real ~26-minute chunks that never reach the fold check at all. Test contract: `tests/test_transcript_quality_guard.py::TestRuntFoldBoundaries`. Reviewers: grep for `RUNT_FOLD_MAX_SECONDS` and `TRANSCRIPT_CHUNK_MINUTES_DEFAULT` in any diff touching `_build_transcript_chunks` - a reversion to a ratio, or a bump back toward 50, needs the same seed-shape justification this entry documents. Test contract for the whole feature: `tests/test_transcript_quality_guard.py` (executing, no stubs at the assessor boundary) plus the rewritten `tests/test_chunked_transcript.py::TestAssessChunkCoverage`.
  11. **A block of a chunk's stamps can be misclassified WHOLESALE, and that is invisible to invariants 4 and 2-3 above (issue #158).** Backward-jump (invariant 4) only sees within-one-chunk emission order; the whole-stitched gap/density assessment (invariants 2-3) runs on the SORTED merged list and a shifted block can land inside a DIFFERENT chunk's plausible absolute range, producing no signal there either. `merge_chunked_transcripts` closes this with a POST-CLASSIFICATION, per-chunk window check: immediately after `_classify_and_offset_timestamp` decides a dialogue stamp's placement, the classified value is compared against that chunk's ACTUAL window `[chunk_start - slack, chunk_actual_end + slack]` (`slack = timestamp_tolerance(chunk_duration_seconds)`, reused - not re-derived - from the SAME nominal duration the classifier itself just used). The window's upper bound MUST use the chunk's ACTUAL end (threaded in via the optional `chunk_bounds` parameter, positionally matched to the `chunks` list) and NEVER the nominal `chunk_duration_seconds` - a runt-folded tail chunk's real end extends past the nominal boundary (invariant 10), and a nominal-only window would false-flag its legitimate tail. `chunk_bounds` is opt-in: omitted (every pre-#158 caller, and the existing `tests/test_chunked_transcript.py` calls), `merge_chunked_transcripts` returns the byte-identical pre-#158 dict with no `"_chunk_window_violations"` key at all - `_run_chunked_transcript_url` is the only production caller that supplies it, built in lockstep with `chunk_results` (a chunk that failed/confabulated/discarded contributes no bounds either, keeping the two lists positionally aligned). If `chunk_bounds` is supplied but its length disagrees with `chunks`, one WARNING names both lengths and detection degrades gracefully (any chunk past the shorter list's end is simply not checked) rather than mis-checking a chunk against the wrong bounds or raising. **Severity is TWO independent rules, not one (dual-review addendum).** `_classify_chunk_window_violations` computes, from the RAW per-chunk counts `merge_chunked_transcripts` returns: MAJORITY - out-of-window fraction `> CHUNK_WINDOW_MISMATCH_SEVERE_FRACTION` (0.5, strictly greater - exactly 0.5 is MILD, not severe) AND `classified_dialogue >= CHUNK_WINDOW_MISMATCH_SEVERE_MIN_ENTRIES` (4, inclusive); OR UNANIMOUS - `out_of_window == classified_dialogue` AND `classified_dialogue >= CHUNK_WINDOW_MISMATCH_UNANIMOUS_MIN_ENTRIES` (2). The unanimous rule exists because the majority rule's 4-entry floor left a short-chunk hole: with `chunk_minutes <= 5`, a chunk with 2-3 entries that are 100% out-of-window could never reach 4 classified entries, and the monolithic guard's own `> 300s` window gate (invariant 1) also misses a chunk that short - so a chunk that is completely and unambiguously wrong stayed MILD forever. A single out-of-window entry (`classified_dialogue == 1`) is deliberately excluded from the unanimous rule - one data point is too weak to call systemic, matching the pre-existing single-stray-stamp-is-mild case. Either rule alone sets `QUALITY_FLAG_CHUNK_WINDOW_MISMATCH_SEVERE`; any violation that clears neither sets `QUALITY_FLAG_CHUNK_WINDOW_MISMATCH_MILD` instead (a stray stamp, or a majority with too little evidence, not a systemic misclassification). These are two DISTINCT flag strings (`chunk_window_mismatch_severe` / `chunk_window_mismatch_mild`), matching every other severe/mild pair in this file, so `transcript_quality_flags_are_severe`'s frozenset-membership test can tell them apart - do not collapse them into one shared flag name. The severe flag is a member of `_SEVERE_QUALITY_FLAGS`, so it flows through the EXISTING machinery unchanged: `transcript_status: partial`, `EXIT_PARTIAL`, and the `resolve_mindmap_source` containment check all pick it up automatically via `quality_flags.update(...)` in `_run_chunked_transcript_url` - no new plumbing was added for this. A raw total (`total_violations`, summed across every chunk regardless of which bucket fired) is persisted verbatim as the `transcript_chunk_window_violations` meta field, alongside the other quality metrics. **An unparseable classified stamp counts as neither a violation nor a clean entry.** `timestamp_to_seconds`'s fallback-to-0 on garbage is fine for a sort key but wrong here: 0 silently reads as IN-WINDOW for chunk 1 (window includes 0) and as a VIOLATION for every later chunk (0 < its `window_lo`) - two different wrong answers depending on which chunk emitted the garbage. `_safe_timestamp_to_seconds` (returns `None` on failure, distinct from `timestamp_to_seconds`) gates both `classified_dialogue` and `out_of_window`; a failed parse increments a separate `unparseable` count on the per-chunk record instead, which carries no flag of its own. Label-only, always: the detector counts and flags, it never drops, reorders, or reclassifies a dialogue entry, and it never touches `screen_content`. **Scope, precisely - this catches OUT-OF-BAND placements only, never an in-window wrong-branch decision (do-not-extend-the-claim, mirroring issue #128).** The detector fires only when a classified value lands OUTSIDE the emitting chunk's own real window (the double-offset shape, an implausible-passthrough value, or an overrun past a short final chunk's real end). It structurally CANNOT catch a misclassification whose result stays INSIDE the emitting chunk's own window, because there is no window violation to see - the corrupted number IS a legitimate coordinate inside the chunk that emitted it. The historically observed case is the hour-digit-dropped shape (`docs/solutions/integration-issues/gemini-flash3-vs-pro25-chunked-transcription-20260427.md`, Defect A): content genuinely at `[1:01:33]` (chunk 2's territory) emitted by Gemini as `[01:33]` in chunk 1's response - the classifier reads 93 seconds as plausible for chunk 1 and leaves it there, inside chunk 1's own real window. Do not claim this invariant catches that shape, and do not extend the claim to any other in-window misclassification. Reviewers: grep for `chunk_bounds`, `_chunk_window_violations`, `_safe_timestamp_to_seconds`, and `_classify_chunk_window_violations` in any diff touching `merge_chunked_transcripts` or `_run_chunked_transcript_url` - a caller that re-derives chunk end from `chunk_duration_seconds` instead of threading the writer's own actual bounds reintroduces the runt-fold false-positive this invariant exists to prevent, and a reversion to `timestamp_to_seconds` inside the window-check loop reintroduces the unparseable-stamp miscount. Test contract: `tests/test_chunk_window_mismatch.py`.
- **Topics are a curation layer, and `topics.json` is derived from its inputs, never negotiated with (issue #146).** Topic membership answers "why did the operator pull this video in", which is the opposite direction from `taxonomy.json` ("what does the video say"). Two assertion sources, one union, no third: briefing front-matter `video_ids` keyed by the FIRST path segment under `_briefings/` (so `_briefings/fde/deep-dives/note.md` is `fde`, never `deep-dives`; a briefing in the `_briefings/` root asserts nothing; `nuggets` is in `RESERVED_BRIEFING_DIRS` because nugget briefs are synthesis output keyed by `cited_video_ids`, and admitting them invents a topic named after a command), plus per-video meta.json `topics` stamps from the repeatable `--topic` flag on `process` / `transcript` / `mindmap`. Eight things a future diff must preserve:
  1. **`--topic` gets its OWN writer, `stamp_video_topics`, and must never be routed through `update_meta`.** `update_meta` is the shared SUCCESS-path writer: `meta.update(fields)` would OVERWRITE the `topics` list instead of merging it, it appends to `modes_completed`, and it clears `last_error`. A topic stamp is provenance, not stage completion, and it has to work on a lazy-skip run where no stage ran at all. Modelled on `_record_concepts_error`, with the two standing meta contracts intact: the read is `_read_meta_best_effort(..., raise_on_os_error=True)` (issue #124 - this is a SUCCESS path, so a read that merely failed must not license overwriting a healthy file and destroying `alt_titles` / `skip_modes`), and identity is stamped on every write from whatever the caller's resolver produced (issue #66 - a quarantined read returns `{}`, so a write of only `{"topics": [...]}` leaves an identity-less meta that `_load_video_id_index` skips, re-queueing a full re-transcribe as the price of a free tag). Two limits on that claim, both deliberate: a stamp only FILLS absent keys, never downgrading a healthy on-disk field, and `_transcript_identity_fields` drops falsy values, so a caller who resolved no `video_id` at all (a plain local file with no sibling meta, no dedup hit and no `--video-id`) writes a meta with no `video_id` key. That stamp is invisible to `topics-build`, which joins on the id, so the writer still writes it and WARNs naming the recovery (`--video-id <id>`) - the same shape as the `_standalone` warning in row 7. Like `_record_concepts_error` it never CREATES a meta that did not exist, because a second meta claiming one `video_id` manufactures a dedupe group that never existed.
  2. **The stamp fires even when every stage skips** (amendment 4). That IS the case the flag exists for: backfilling a topic onto a video curated months ago. `register_topic_stamp_target` is called once per command path with the WRITER's own `(channel_dir, prefix)` pair - never a separately re-derived one (PR #136) - stamps immediately when the meta is already on disk, and otherwise leaves a pending target that `flush_topic_stamps` applies from main()'s `finally`, so a deliberate-skip early return and a `sys.exit(EXIT_PARTIAL)` both still record it. An early stamp is safe because no downstream writer touches `topics`.
  3. **The build joins through `video_id`, via `collect_corpus_videos`, never a filename rebuilt from title and date parts.** Title rotation moves the filename and leaves the id alone, so a reconstructed path would report a present video as `unresolved` - a permanent false gap on a healthy corpus, which is the PR #136 failure class. Duplicate metas for one id UNION their topics rather than inheriting the completeness winner's list: an operator can stamp `--topic` before a retitle, and taking only the winner would silently delete that membership.
  4. **Determinism: every collection sorted, no value from the wall clock.** `first_seen` follows an explicit input-only chain (front-matter `date` -> `created_at` -> the filename's leading `YYYY-MM-DD` -> no contribution), because a survey of the live corpus found 20 of 30 topic-foldered briefings without a `date:` key and PyYAML returns a `datetime.date` for `date: 2026-08-22` but a `str` for the quoted form. A clock reading would make every rebuild a diff on a cloud-synced corpus.
  5. **A topic with zero memberships is omitted** (a topic is a membership fact), and an unresolved briefing id is KEPT with `"unresolved": true`, excluded from the channel rollup, and counted in the summary line. There is no `--remove-topic`: removal happens at the asserting source, and the build never argues with its inputs.
  6. **Malformed input degrades, it does not quarantine or abort.** A non-string `video_id` in a meta is SKIPPED with a WARNING naming the file, never coerced with `str()` (that would merge a malformed `123` into a real `"123"` identity), because `sorted()` refuses to order an int against a str and one bad meta used to abort the whole build; the same guard covers a non-string `channel`, which falls back to the folder the meta lives in. A briefing's `video_ids` entry must be a non-empty **string**: PyYAML types an unquoted `yes` as `True`, `123` as `int` and `2026-08-22` as `datetime.date`, and coercing those manufactured members for videos that never existed, so non-strings are dropped with ONE WARNING per file naming the path and the count (`bool` subclasses `int`, so a single `isinstance(..., str)` covers all three shapes). **A malformed `topics` field degrades, it does not quarantine.** A scalar, a bare string, or a mixed list keeps the usable string entries with a WARNING - discarding `alt_titles`, `skip_modes` and transcript status over one bad OPTIONAL tag costs far more than the tag is worth. `status` and `search --topic` fail soft with ONE actionable message naming `topics-build` when `topics.json` is absent or unparseable; never a traceback, never silent emptiness. No staleness detection in v1, same convention as `taxonomy.json`.
  7. **A `--topic` stamp on a `_standalone` video is written but never joined.** `collect_corpus_videos` skips `_`-prefixed dirs (`_briefings`, `_headlines` depend on that), so the tag lands in the meta and can never appear in `topics.json`; `stamp_video_topics` logs a WARNING naming the recovery (`--channel <name>`) rather than failing, because the meta write itself is still correct. Loosening the underscore rule, or making `build_topics` walk `_standalone` separately, was rejected as out of scope.
  8. **`topics-build` is in `CONFIG_BACKUP_COMMANDS`** (it writes at the corpus root) and requires no `channels:` - it is a derived rebuild over corpus artifacts, like `taxonomy-build`. It never writes `taxonomy.json`. Out of scope by decision: topic pages in `_wiki/`, automatic topic inference from concepts, any influence on ranking or the interest model (issue #115 untouched), and a `config.yaml` schema change. Test contract: `tests/test_topics.py`, plus `tests/test_config_backup.py::TestEveryMutatingCommandSnapshots`.
  9. **Issue #188 added two READ surfaces and one resolver, nothing else.** `resolve_topic_filter` is the ONE place a `--topic` slug becomes a video-id set (fail-soft topics.json read, unknown-slug message listing the known slugs) - `search` and `nugget` both go through it, so the two surfaces cannot drift about what a slug means; a third `--topic` consumer must reuse it. `search`'s `query` positional became OPTIONAL: with `--topic` and no query, `render_topic_listing` prints the members straight from topics.json (a pure read + render - the test contract proves NO retrieval call happens); with neither query nor topic the exit code stays 2 (the pre-#188 missing-positional code, asserted as a CODE per the #185 rule); `--vector` without a query is refused the same way BEFORE any retrieval. `nugget --topic` scopes retrieval AT the index query - `hybrid_search(..., video_ids_filter=...)` appends a `video_id IN (...)` predicate built by `video_ids_predicate` (every id through `escape_sql_string_literal`, the #183 invariant-6 rule; an empty usable set matches NOTHING, never everything) - and NOT by post-filtering the global pool: Gate 1 measured the post-filter shape starving a 19-video topic down to 2 surviving excerpts, because membership had to fight 2,300+ other videos for pool slots first. The semantic contract still matches `search --topic` (filters, never reorders or boosts); only the mechanism differs, because a synthesis's quality IS its evidence base. On the scoped path `hybrid_search` sizes its candidate pool to the SCOPE (`max(fetch, min(1000, 15 * len(ids)))` - measured: a single chunk-dense member consumed 23 of 95 pool slots, leaving a member whose best chunk ranked 131st structurally unreachable at any `--limit`); this deliberately does NOT touch the unscoped pool that issue #190 invariant 4 pins. The user's `--limit` passes through untouched (`--limit >= topic size` covers the whole thread), a belt post-check drops any hit that leaks past the predicate WITH a WARNING (a filter that silently stops filtering must not feed the synthesis), the unknown-slug check runs BEFORE `hybrid_search` (probe before you pay), and a scoped brief records `topic: <slug>` in its front matter (additive optional key; the `nuggets` folder is in `RESERVED_BRIEFING_DIRS`, so the key can never be read back as a topic ASSERTION). `search --topic` kept its #146 post-filter + `TOPIC_FILTER_OVERFETCH` shape at the time, on the grounds that re-plumbing it onto the predicate was its own decision rather than a consistency tidy-up. **Issue #203 IS that decision, and it went the other way - see item 10.** Both surfaces stay read-only consumption per the topics-are-a-curation-layer contract above. Test contract: `tests/test_topics.py::TestQueryLessTopicListing`, `::TestNuggetTopicScoping`.
  10. **`search --topic` with a query scopes at the source in BOTH modes, and `TOPIC_FILTER_OVERFETCH` is retired on purpose (issue #203).** The post-filter #188 left in place starved exactly as predicted: on the live corpus a 19-member topic had to out-rank 2,300+ other videos for `limit * 5` pool slots, and the reproduction returned NOTHING for a query the unscoped search answered well. Concept mode starved the same way, less visibly - 1 of 19 members returned, because the matched concept spanned 188 videos and the top-25 window filled with non-members. Raising the multiplier was explicitly not the fix (#188 measured one chunk-dense member consuming 23 of 95 slots); it only moves the cliff. Eight things reviewers must protect:
      - **Vector mode passes `video_ids_filter=topic_ids` and the user's OWN `--limit`** to `hybrid_search`, so it inherits the same index-level `video_id IN (...)` predicate and scope-sized candidate pool `nugget --topic` has used since #188. Post-fix Gate 1 on the same command: 0 results -> 5 relevant results across 4 channels, every one a member. The unscoped pool (#190 invariant 4) is untouched, and so is the unscoped path generally - the eval harness calls `hybrid_search` directly with no filter and measured **1/25, unchanged**, instrument 50/50.
      - **Concept mode's scope is EXACT, and that is a different mechanism from vector mode's, not a copy of it.** `search_corpus` gained a `video_ids_filter` applied AFTER the relevance sort and BEFORE the `[:limit]` cap. By that point the function has materialized every video carrying the concepts the query SELECTED, so the scope costs nothing extra and gives a guarantee no multiplier can: a member ranked last of hundreds still survives. State the reason exactly that way - "materializes every matching video in the corpus" is FALSE on the partial path, and reading "exact" wider than the VIDEO ranking is the over-claim the next-but-one bullet exists to close. Do not "unify" the two mechanisms onto one - concept search has no index to push a predicate into, and vector search cannot afford to materialize the corpus. Same convention on both, though: `None` means no filter and an EMPTY SET matches NOTHING, tested with `is not None` and never truthiness.
      - **The ordering inside `search_corpus` is the whole fix.** Sort, then scope, then cap. A diff that caps first (or that re-adds a caller-side multiplier to compensate) reintroduces the defect. Test contract: `tests/test_topics.py::TestSearchCorpusVideoIdsFilter::test_the_scope_is_applied_before_the_limit_cap`.
      - **One belt and one no-match message, shared with `nugget` - and BOTH surfaces prove they CALL it, not just that it works.** `drop_topic_leaks` and `topic_no_match_message` are module-level helpers both surfaces call; a second inline copy in either command is how the two surfaces drift. `nugget` has had a caller-level leak test since #188, and `search --vector --topic` now has its mirror (`TestSearchVectorTopicBeltAndComposition`), because an executing test reviewer proved the gap the obvious way: deleting the `drop_topic_leaks` call from `cmd_search`'s vector branch left all 148 tests green. A helper that is unit-tested but never proven to be CALLED is the same blind spot as a stub agreeing with its own assertion (the PR #136 rule). The belt also runs BEFORE the empty check on both surfaces, so a fully-leaking retrieval ends as an empty scoped result rather than a list of out-of-topic hits.
      - **The two empty results stay distinguishable, and the vector one CHANGED MEANING.** Concept mode still ranks corpus-wide by concept and then narrows, so `topic_filter_emptied_message` ("the ranking returned N, the topic filter removed all of them") is still true and diagnostic there. Vector mode's ranking is *already* scoped, so there is no post-filter to blame and it uses `topic_no_match_message` instead. Neither branch may point at rebuilding the index that just answered for the rest of the corpus - the #146 Finding-6 contract, preserved through the mechanism change.
      - **`Raise --limit` was dropped from the emptied message deliberately.** Under an exact scope no member is lost to rank, so a bigger cap cannot recover one - a remedy that cannot work is the same "trains them to distrust the message" failure the helper exists to prevent, one layer in. Test contract: `::TestEmptyTopicResultNamesTheRightRemedy::test_the_emptied_message_no_longer_offers_the_limit_remedy`.
      - **The retirement is ASSERTED.** `test_the_retired_constant_is_gone_on_purpose` fails if `TOPIC_FILTER_OVERFETCH` reappears on the module. The constant's only job was compensating for a post-filter that no longer exists; leaving it would invite a future edit to re-plumb the post-filter around it. `TestTopicFilterOverfetchIsLoadBearing` was retired WITH it and replaced by `TestTopicScopeIsExactNotOverfetched`, whose fixtures rank the member dead last behind 40 competitors - proving the strictly stronger property (rank does not matter at any depth) rather than merely that a window was widened. Both halves were falsified: reverting the concept-mode ordering fails the exactness tests, reverting the vector branch to a post-filter fails the predicate tests.
      - **Do not extend the exactness claim past the VIDEO ranking - concept mode keeps one cliff, one stage earlier (executing-reviewer finding).** `search_corpus`'s partial-match path feeds video lookup from `matching_concepts[:5]` (the #189 selection rule, deliberately untouched here), and that cut runs BEFORE the topic scope. So a member whose only matching concept ranks sixth is unreachable at any `--limit` - reproduced on a scratch corpus: six concepts partial-match, the member carries only the sixth, `--limit 50` returns nothing and prints the emptied message. What #203 makes exact is the VIDEO list given the concepts the query selected; it does not widen concept SELECTION, and widening it would rewrite #189's frozen exact-vs-partial contract, which is a different decision. `--vector` has no such cut, and the query-less listing always shows every member. Test contract: `::TestTopicScopeIsExactNotOverfetched::test_the_partial_concept_cut_is_the_remaining_cliff`, which exists to keep this scope honest the same way `test_does_not_claim_the_monolithic_early_stop_shape` does for #128.
      - **Two accepted, user-visible consequences, both recorded rather than patched.** (a) With `--topic --vector` and NO index at all, `hybrid_search` returns `[]` and the scoped branch now prints the no-match message instead of `Is the index built?`. `hybrid_search` still logs `ERROR Search index not found` immediately above it, and `nugget --topic` has behaved this way since #188, so this is a wording change, not a lost signal - but it does narrow the "neither branch may point at rebuilding the index that just answered" claim above: here the index answered nothing. Fixing it properly means giving `hybrid_search` a way to distinguish "no index" from "no results", which is a change to a function this diff deliberately does not touch. (b) The count in the concept-mode emptied message changed scale: it was `len(videos)` capped at `limit * 5`, and is now the uncapped corpus-wide matched count, so the issue's own 188-video concept now reports "The ranking returned 188 result(s)" where it used to say 25. More accurate, and worth knowing before someone reads it as a regression.
      - **Out of scope by decision:** the query-less listing (`render_topic_listing`) is untouched; `nugget`'s retrieval is a helper refactor only, byte-identical; and concept mode's *concepts* list stays corpus-wide under `--topic` (only the video list is scoped), which is the pre-existing contract. Test contract: `tests/test_topics.py::TestTopicScopeIsExactNotOverfetched`, `::TestSearchVectorTopicBeltAndComposition`, `::TestSearchCorpusVideoIdsFilter`, `::TestSharedTopicScopeHelpers`, `::TestEmptyTopicResultNamesTheRightRemedy`.
- **Timestamp helpers live in `scripts/timestamp_utils.py`, and that module is the import-weight firebreak (issue #152).** Do not duplicate `normalize_timestamp`, `normalize_mm_ss_zero_timestamp`, `should_reinterpret_part_as_mm_ss_zero`, `timestamp_tolerance`, `parse_time_to_seconds`, or `timestamped_url` into other scripts. The last two moved here from `video_intel.py` and `intel_graph.py` respectively; both original homes keep a re-export so every existing importer and `tests/test_utils.py` / `tests/test_intel_graph.py` still resolve them from the old module. **`timestamp_utils.py` must never gain a dependency beyond the standard library.** It is what lets the standalone read-only analytics scripts (`wiki_concepts`, `wiki_atlas`, `lead_lag_report`, `lead_lag_viz`, `burst_report`) import without dragging in the curate stack: each of them imports `timestamped_url` from HERE, never from `intel_graph`, because `intel_graph` imports `video_intel`, which hard-imports `googleapiclient` at module level. Repointing one of those five back at `intel_graph` silently re-couples that script to the full curate stack - measured before #152, all five failed to import at all when `googleapiclient` was absent. Test contract: `tests/test_timestamp_utils.py::TestStandaloneScriptImportIsolation` (subprocess, heavy modules blocked via `sys.meta_path`). `_classify_and_offset_timestamp` in `scripts/video_intel.py` MUST call `normalize_timestamp` before classification — issue #58 caught the regression when chunk-3 of a 2h+ Tucker transcript produced 89 `Implausible timestamp [100:XX:XX]` warnings because PR #51 ported the classifier but skipped the normalize pre-pass. Reviewers: grep for these six names in any new diff. A definition outside `scripts/timestamp_utils.py` needs pushback; the only legitimate non-definition hits are the re-export lines in `scripts/video_intel.py` (`parse_time_to_seconds`) and `scripts/intel_graph.py` (`timestamped_url`), plus `scripts/translate_video.py`, which shares the normalization helpers and is operationally separate.
- **Chunked transcription caps Gemini's thinking budget.** `_run_chunked_transcript_url` builds a model-aware ThinkingConfig via `_make_thinking_config_for_transcript` and threads it through `call_gemini`. Without this cap, Gemini's dynamic-thinking default stochastically burns output tokens on thinking — Tucker chunk 2 produced 1,484 output tokens with 15,013 thinking tokens, truncating ~47 minutes of content (issue #58 Gate 2). Mirrors translate_video.py's `SRT_DEFAULT_THINKING_BUDGET=128` pattern but model-aware: Gemini 3 Flash uses `thinking_level="minimal"` (Flash-exclusive lowest level), Gemini 3 Pro uses `"low"`, Gemini 2.5 Flash uses `thinking_budget=0`, Gemini 2.5 Pro uses `thinking_budget=128`. Reviewers: any chunked-Gemini call in `scripts/video_intel.py` MUST pass a `thinking_config` derived from the helper, OR a per-chunk content sanity check that catches the silent-truncation outcome. The defense-in-depth helper `_assess_chunk_coverage` flags a chunk as `thin` when `assess_transcript_artifact` reports it severe (issue #157 rewrite - the old `<50% of allotted window` span-ratio check is gone, see the quality-assessor guardrail entry below) and propagates to `transcript_status: partial`. Removing the cap or the sanity check needs explicit justification.
- **Config snapshots are AUTOMATIC and mandatory; never re-document them as a manual step.** `backup_config_if_changed(output_dir)` runs before any corpus-mutating command touches the corpus - from `cmd_scan` itself (immediately before its first fetch, the precise point of record) and from `main()` for every command in `CONFIG_BACKUP_COMMANDS`. The duplicate call is deliberate: the content compare makes the second a no-op, a library caller invoking `cmd_scan` directly still gets a snapshot, and a config edit followed by `process --url` instead of a scan is still captured. **This exists because the manual routine FAILED.** The corpus went 2026-07-22 to 2026-08-17 with no snapshot while the channel list was actively edited (YC Shorts curation, `skip_video_ids` blocklists, a `headline_digest` flag). The routine was written down and simply did not run, because it depended on someone remembering. A diff that removes the call, moves it below the first fetch, or replaces it with a documented habit re-opens a silent month-long gap. Six invariants:
  1. **Content-compared, never time-based.** Writes only when the config differs from `config.latest.yaml`, so ten scans a day do not litter ten snapshots and a snapshot's existence genuinely means the config changed there.
  2. **Dated snapshots are immutable; only `config.latest.yaml` is overwritten.** A second, DIFFERENT edit the same day becomes `config.<date>-2.yaml`. Clobbering the morning's snapshot destroys exactly the history the backup exists to preserve. An identical same-day config writes no duplicate.
  3. **It never aborts the caller.** A backup failure logs a WARNING and returns `None`. If `output_dir` is an unmounted cloud drive the scan is doomed anyway and must surface THAT error, not a confusing failure inside the backup helper.
  4. **A failure is never silent.** Every non-write path that is not "nothing changed" logs a WARNING - including the env-var case, where `VIDEO_INTEL_OUTPUT_DIR` names a directory and there is no config file to copy. A backup that quietly stops backing up reproduces the original gap.
  5. **An unreadable `config.latest.yaml` is NOT proof the config is unchanged.** That path falls through and writes a fresh snapshot. Treating a transient cloud-mount read error as "unchanged" is how a real edit goes unsnapshotted.
  6. **`CONFIG_BACKUP_COMMANDS` must stay in sync with the command surface, and the inventory is anchored to argparse, not to the dispatch chain (issue #151).** `tests/test_config_backup.py::TestEveryMutatingCommandSnapshots` fails on any subcommand that is in neither the backup set nor the explicit read-only allowlist - so a NEW corpus-mutating command cannot silently opt out. The read-only surface (`search`, `nugget`, `status`, `briefings`, `profile`) is excluded on purpose: `profile show` promises zero filesystem side effects. Reviewers: adding a subcommand means classifying it. Two extractors, and the split is load-bearing: `_dispatch_commands` walks the AST of `main()` (an `ast.Compare` visitor handling `==`, `in (...)`, and `match`/`case`), replacing a regex that was quote-sensitive - a branch written with single quotes vanished from the inventory while `assert dispatched` still passed, so the test kept passing while silently losing its only guarantee. But ANY extractor over the dispatch chain can only see the shapes it knows, so `_registered_commands` independently walks `subparsers.add_parser("<name>")` across the module: a subcommand cannot exist without registering there, which makes that inventory undodgeable. Three consequences a future diff must preserve: classification runs over the UNION of both (a registered-but-undispatched command, or the reverse, still gets classified); the phantom check reads the argparse registry ALONE, because `in (...)` support means a stray `args.command in (...)` guard inside `main()` could otherwise pad the dispatch inventory and mask a stale backup-set entry; and `test_dispatch_inventory_matches_the_argparse_registry` asserts the two agree, so an unrecognized dispatch shape fails LOUDLY instead of silently shrinking the set. A `_registered_commands` that drops the `id == "subparsers"` receiver filter would swallow the nested `profile_actions.add_parser` sub-actions as if they were top-level subcommands. Test contract: `tests/test_config_backup.py`.
- **The transcript model is chosen by MEASUREMENT against the incumbent, never by spec sheet - and the invariant is BOUNDED thinking, not zero.** Rewritten 2026-08-18 after the first version of this guardrail was shipped on a synthetic text benchmark and was wrong on two counts. Four things reviewers must hold:
  1. **Bounded, not zero.** `DEFAULT_MODEL` must resolve to an explicit `thinking_config` in `_make_thinking_config_for_transcript` - never `None`, which lets Gemini's DYNAMIC default run. That is the real issue #58 Gate 2 vector: the 15,013 thinking tokens that truncated Tucker chunk 2 came from the unbounded default. `minimal` and `low` both satisfy it. The earlier demand for a model that could reach ZERO thinking was retired by measurement: on VIDEO input, thinking tokens came back 0 for every model tested, so zero-vs-bounded was never the live variable - it was an artifact of a TEXT prompt. Test contract: `tests/test_per_step_models.py::TestDefaultModelHasBoundedThinking`.
  2. **`minimal` is actively harmful for transcription.** It collapses minutes of video into a single timestamped block. Measured: `gemini-3-flash-preview` produced 14 stamped segments where `gemini-3.7-flash` produced 52 on the same 10-minute window, with a worst-case gap of 209s vs 78s. Since every retrieved chunk is surfaced as `&t=<seconds>`, that gap IS the deep-link error the corpus exists to avoid. This is the same monolithic-collapse shape issue #128 flags as having no output-budget signature.
  3. **`gemini-3.7-flash` rejects `minimal` with a 400, and silently IGNORES `thinking_budget=0`** (911 thinking tokens still billed on a text prompt). The silent-ignore is the more dangerous of the two because nothing surfaces it. `_NO_MINIMAL_THINKING_LEVEL` stays a denylist, not an allowlist, so a future Flash that regains `minimal` needs no edit.
  4. **Cost is a scored dimension, and cheaper-is-newer is false here.** 3.7 costs MORE than the `gemini-3-flash-preview` it replaced ($0.332 vs $0.227 per video-hour measured), both because its per-token rate is higher and because finer segmentation emits more output tokens. The promo rate expires **2026-12-31** and doubles. The owner accepted that trade for precision; a future reviewer must not "restore" the cheaper model on cost grounds without re-running the A/B. The harness is `scripts/model_eval.py`, fixtures `tests/evals/model_fixtures.yaml`, scorecards `tests/evals/model-cards/`. Changing `DEFAULT_MODEL` or config `model:` without a scorecard for the new model is the regression - see `specs/agent-rules.md` sec.6.
- **Voyage batch-halving has a floor.** `_embed_batch` recursively halves batches that trip Voyage's per-batch token cap, but stops at `MIN_BATCH_SIZE` (default 4) and re-raises. Removing the floor or lowering `MIN_BATCH_SIZE` to 1 turns a pathological-chunk error into infinite recursion. Token-cap detection matches on EITHER `"max allowed tokens"` OR `"tokens per submitted batch"` (two stable substrings, hedging against minor SDK reword); narrowing this to a single substring re-introduces the issue #44 silent-regression risk. Token-cap detection must take precedence over rate-limit detection: a message containing both substrings means split, not backoff. The pending queue is depth-first (prepend halves, pop front) so worst-case API call count on a fully-pathological input is `log2(VOYAGE_BATCH_SIZE / MIN_BATCH_SIZE) + 1`, not the full binary-tree size — flipping to breadth-first would multiply API spend on failure. Reviewers: grep for `MIN_BATCH_SIZE` in any diff touching `_embed_batch` and confirm the floor, the precedence, the substring set, and the depth-first queue order are still in place. Test contract is `tests/test_index.py` (`TestEmbedBatchRecursionBound`, `TestEmbedBatchErrorPrecedence`, `TestEmbedBatchTokenCapAlternatePhrasing`, `TestEmbedBatchSpendSummary`).
- **Wrapper normalization runs at BOTH the full-parse and salvage layers.** `_wrapper_to_envelope_dict()` rebuilds a flat envelope from Pro's `[{"task": ..., "output": [...]}, ...]` shape ([issue #45](https://github.com/dzivkovi/video-intel/issues/45)). It is called from `try_parse_transcript_json` (post-parse) AND `_normalize_task_wrapper` (text-level pre-salvage). Both layers matter: a Cyrillic-corrupted wrapper takes the salvage path, but a *clean* wrapper full-parses successfully and would silently produce an empty transcript with `transcript_status: "complete"` if only the salvage path were normalized. Reviewers: any wrapper-handling diff that touches one layer and not the other needs pushback. The helper requires `task in _KNOWN_TASK_KEYS` — a wrapper with an unknown task name returns `None` so callers pass the original through (preventing silent overwrite-with-empty).
- **Cyrillic stripping is scoped to `_normalize_task_wrapper` only.** `_strip_cyrillic_for_structure()` exists to give `json.loads` a chance at the wrapper shape when a Cyrillic intrusion straddles a structural position. Do not promote it to a global pre-strip in salvage; issue #45 rejects that on false-positive grounds for verbatim foreign content (song titles, brand names, multilingual speech). Reviewers: grep for `_strip_cyrillic_for_structure` in any diff that calls it from outside `_normalize_task_wrapper` — that path needs pushback.
- **A scoped write must be scoped on BOTH sides - what it collects AND what it replaces (issue #183).** `build_search_index` filtered collection to one channel and then ran an unconditional `create_table(..., mode="overwrite")`, so `index --channel X` replaced a ~40-channel index with one channel, printed `Indexed N chunks` and exited 0; every later `search` silently returned single-channel results. Measured on a two-channel scratch corpus before the fix: 28 of 63 rows deleted, exit 0, and a subsequent `search --channel <the other one>` answered "No results". Seven invariants:
  1. **`--channel` is incremental, never an overwrite.** Embed the channel, then `table.delete(channel = ...)`, `table.add(records)`, `table.optimize()`. The `optimize()` is not optional: appended rows are brute-force searched and **invisible to the FTS indexes** until the table is compacted, so dropping it silently half-breaks the channel that was just re-indexed. Test contract: `tests/test_scoped_index.py::TestScopedIndexIsIncremental::test_scoped_rows_are_searchable_after_the_incremental_write`.
  2. **Delete AFTER embed, never before.** The comment in `_embed_batch`'s failure path promises that a mid-embed failure leaves the previous index intact; deleting first would break that promise on the scoped path only, where it is hardest to notice.
  3. **A scoped run against a database with no index is REFUSED, above the Voyage call.** An index born holding one channel reproduces the same silent single-channel-search window, unbounded. The placement is the same probe-before-you-pay rule as `probe_atomic_writes`, and the test asserts the Voyage stub was never called - an exit-code-only assertion passes either way.
  4. **`if force and not channel_filter`.** Under incremental semantics `--channel X --force` already means "re-embed X"; dropping the table there is exactly the damage this ticket reports.
  5. **Schema drift is checked in TWO places, and the second one is the load-bearing half.** `index_schema_mismatch` compares column NAMES before embedding - free, and it catches the common drift. But it is structurally blind to a vector DIMENSION change (a new embedding model keeps every column name identical), and that blind spot is destructive on the scoped path: the guard passes, `delete` commits, `add` raises an Arrow cast error, the channel is erased from the index, and a re-run fails identically forever. Both review layers of PR #192 found it and one reproduced it against real LanceDB. So `vector_dimension_mismatch` runs AFTER embedding and BEFORE the delete - it needs the real vectors, and that is the price of catching it. It returns `None` when the width is unreadable: an unreadable schema is not evidence of drift, and refusing on it would block healthy runs. LanceDB will not do this for you - measured on 0.30.2, `add` with a record MISSING a column succeeds and null-fills, while an EXTRA column raises.
  5b. **`delete` + `add` + `optimize` are wrapped, and a raise there names the channel.** They are separate commits, so a failure between them leaves the channel with zero rows while every other channel is intact - a silent hole of exactly the #183 shape, inverted. The exception still propagates (non-zero exit is the honest signal), but it must never propagate without an ERROR naming the channel, saying its rows may now be missing, and giving the `index --force` recovery. Test contract: `tests/test_scoped_index.py::TestTheDestructiveWindowBetweenDeleteAndAdd`.
  5c. **An emptied channel is not silent.** A scoped run can only REPLACE rows, never retire them - the no-records guard sits before the delete, which is invariant 7. But a channel emptied by `prune-shorts --apply` takes that identical path, and its stale rows keep answering searches; the docs now actively recommend `--channel` for exactly that re-index. The warning uses the channel FOLDER's existence to tell a genuinely-emptied channel from a typo, so a typo raises no false alarm. `--force` passed with `--channel` logs what it did rather than being silently ignored - on a ticket about a flag doing something other than its name, a silently-ignored flag is the same sin.
  6. **Every channel predicate goes through `escape_sql_string_literal`.** LanceDB predicates are SQL text and an apostrophe in a channel name raises (`o'brien`). One helper covers both sites - the scoped delete and `hybrid_search`'s where clause - so a fix at one cannot leave the other broken; `tests/test_scoped_index.py::TestEscapeSqlStringLiteral::test_hybrid_search_uses_the_same_helper` AST-walks the module and fails on any raw interpolation into a channel predicate.
  7. **A mistyped channel name must stay harmless.** `if not all_records: return 0` sits before any write, so only a VALID channel name ever reached the destructive path - which is precisely what made the original defect rare and confusing. Do not "tidy" that guard away.
  Same-shape audit (clean at the time of the fix): `dedupe` and `prune-shorts` iterate `[channel_filter] or all` but mutate only per-channel files; `taxonomy-build` and `topics-build` have no `--channel` and rebuild from all inputs; `concepts --channel --force` writes per-video files. `index` was the only command where a scoped collect fed a whole-corpus overwrite. Reviewers: any NEW `--channel` flag needs the same both-sides check before it ships.
- **Probe before you pay.** `probe_atomic_writes()` runs *before* any Voyage embedding call in `build_search_index`. Reordering that sequence (probe after embedding, probe conditional on a flag, probe only in verbose mode) costs ~$0.30 per failed run. See [ADR-0016](docs/adr/ADR-0016-vector-db-path-config.md). Reviewers: grep for `probe_atomic_writes` in any diff touching `build_search_index` or `index` CLI.
- **Timestamps are data, not decoration.** Every retrieved chunk carries `timestamp_seconds`, surfaced as `&t=<seconds>` in result URLs. Changes to chunking, dedup, or rendering that drop or corrupt that field break user-visible behavior. Reviewers: grep for `timestamp_seconds` in diffs touching `hybrid_search`, `_select_hits_by_video`, or chunk rendering.
- **A parser's accepted timestamp shape must match what the WRITER emits, and the minute field is UNBOUNDED (issue #195).** `chunk_transcript`'s two entry-boundary regexes allowed `\d{1,2}` before the first colon while `_captions_timestamp` renders `MM:SS` with minutes past 59 - so `[100:30]` (1h40m30s) was not seen as a boundary and every cue past 99:59 folded into the preceding chunk. This is the "Timestamps are data" rule above being violated by chunking, silently, at scale: nothing logged, nothing failed, the index built cleanly. Measured on the live corpus: 25 transcripts across 10 channels, 27,798 cue lines - 23 `transcript_source: youtube_captions`, 1 `gemini` (the everyinc 2026-07-31 file, whose first newly-matching line is a `[100:18]` dialogue stamp), 1 with no `transcript_source` field. The captions renderer is the SYSTEMATIC producer of the shape, but `merge_transcript_json` renders each entry's `start` verbatim, so the Gemini path emits whatever two-part stamp the model returned - do not claim Gemini "cannot" produce it. Gate-1 A/B on two real transcripts: the 2026-06-09 Tokyo captions transcript went from 328 chunks with a single 134,344-character chunk to 1,010 chunks with a 263-character maximum, and its last chunk start moved from 5,973s to 31,446s - a **seven-hour** deep-link error on every hit in the tail. Five things reviewers must protect:
  1. **`ENTRY_TIMESTAMP_PATTERN` is the single definition and both boundaries build from it.** They had the identical narrow shape and would drift apart again if each carried its own literal. `tests/test_chunk_boundary_minutes.py::TestOneSharedBoundaryPattern` walks `chunk_transcript`'s source for every `re.match(` line and fails on any that does not use the constant.
  2. **The walk has a companion that proves it is not vacuous.** `test_the_walk_finds_both_boundary_matches` asserts the walk finds exactly 2 lines and that one of them is the SCREEN branch - falsified by hoisting a boundary into a precompiled module-level regex, which hides it from a source walk while leaving the remaining test comparing an empty list against itself. Same tautology class as the issue #182 field-inventory walk.
  3. **`\d+`, not `\d{1,3}`.** A capped field just moves the cliff: `\d{1,3}` breaks again at 16h40m, and the affected population is exactly the marathons and multi-hour conference days. `_parse_timestamp_seconds` already handles a two-part value with minutes past 59 (`"125:30"` -> 7530), so only the boundary match ever needed changing.
  4. **Widening must not turn prose into a chunk start.** `test_a_non_timestamp_bracket_line_is_not_a_boundary` pins that `[not a timestamp]` and `[12]` stay inside the current entry; the seconds field stays `\d{2}`, which is what keeps a bare bracketed number out.
  5. **The renderer-driven fixtures are deliberate, and there are TWO of them - one per writer - so retiring one never licenses narrowing the pattern.** A hand-typed fixture can agree with the parser while disagreeing with the writer - the exact divergence this issue reports - so `TestParserMatchesTheCaptionsRenderer` builds its body through the real `_build_captions_transcript_body` and asserts `[100:00]` appears in it, and `TestParserMatchesTheGeminiWriter` drives the real `merge_transcript_json` with a `[100:18]` start and proves the rendered line chunks as its own boundary. If `_captions_timestamp` ever moves to `HH:MM:SS` (the alternative issue #195 raised), the captions companion fails ON PURPOSE and should be retired deliberately - but the Gemini companion still holds, because `merge_transcript_json` renders model-supplied stamps verbatim and the corpus already proves the model emits the two-part shape past 99 minutes. Widening the regex was needed regardless, because the 25 transcripts already on disk carry the old shape. **Remediation is operator-run and has TWO consumers**: `index --channel <name>` per affected channel for the LanceDB index, and `intel_graph.py load --force` for the DuckDB segment store - `chunk_transcript`'s `timestamp_seconds` feeds both, so re-indexing only one leaves the other serving the hours-off deep-links. The markdown on disk is correct, only its chunking was wrong, so this is a re-index and never a re-transcription. Known accepted trade (measured in review): the old `\d{1,2}` cap doubled as an accidental sanity bound, so a HALLUCINATED two-part stamp past 99 minutes (e.g. a `[1234:56]` on a 12-minute video) now becomes a real chunk with an absurd deep-link where it used to be silently absorbed - tracked as its own issue rather than patched here, because the right home for a duration-vs-stamp guard is a design call (chunk-time bound vs the #157/#158 quality machinery). Test contract: `tests/test_chunk_boundary_minutes.py`.
- **An eval number is only a retrieval measurement once the ruler is proven intact (issue #190).** `hybrid_search`'s one-chunk-per-video output capped `timestamp_precision` at `distinct expected videos / expected hits` for any golden query expecting several windows inside one video - below its own threshold on 5 of the 25 queries (Q01-Q04, Q11), which therefore could never pass no matter how good retrieval was. That defect scored exactly like a retrieval failure and sat inside the quoted 1/25 baseline for over a year. Six things reviewers must protect:
  1. **`dedup_by_video` decides HOW MANY chunks per video come back, never WHICH videos.** `_select_hits_by_video` ranks videos by their best chunk and truncates to `limit` FIRST, in both modes; `dedup=True` then returns one chunk per video and `dedup=False` returns every chunk of those same videos. The video set and video order are identical either way - empirically confirmed by the post-fix eval, where all 25 queries' `recall_at_k` scores were byte-identical to the pre-fix run while `timestamp_precision` rose on two. A rewrite that derives the frontier by sorting chunks first and taking first-appearance LOOKS equivalent and is not: it diverges from the pre-#190 tie-break when a video's lower-scoring chunk precedes another video's equal-scoring best chunk. Test contract: `tests/test_hybrid_search_dedup.py::TestSelectorPreservesTheVideoFrontier::test_dedup_true_matches_an_independently_written_pre_190_algorithm`, which transcribes the old algorithm independently rather than comparing the new function to itself, and is falsified by exactly that rewrite.
  2. **Never flip the default to `False`.** `search --vector` prints one line per video and labels them videos; `nugget` feeds every returned chunk to Gemini, so prompt size and the persisted source list scale with list length. Both are length-sensitive and both must keep the default. `tests/test_hybrid_search_dedup.py::TestProductionCallSitesKeepDedup` AST-walks the module and fails if any production call site passes `dedup_by_video` at all - the eval harness is the only intended non-default caller.
  3. **`k` and `rank` count VIDEOS, not array positions.** `RecallAtKMetric` and `MRRMetric` route through `distinct_videos_in_order` because the harness now receives several chunks per video; an array-position reading would SHRINK recall as a side effect of seeing more evidence. On a one-chunk-per-video list the generalization is the identity, which is what keeps historic numbers comparable. `ChannelCoverageMetric` and `TimestampPrecisionMetric` are deliberately untouched.
  4. **The pool is not enlarged, on purpose.** `fetch_count = max(50, limit * 5)` bounds how many windows per video `dedup=False` can expose. Raising it for the eval would inflate the score and stop the eval measuring what the product surfaces; a window whose chunk never entered the pool is a genuine ranking failure. Do not "fix" a residual `timestamp_precision` miss by growing the pool.
  5. **The measurability audit is a SEPARATE suite and must stay separate.** `tests/evals/test_instrument.py` fails when a gating threshold cannot be reached by any retriever given the harness configuration and the index on disk (a dead `video_id`, a video with no chunk inside an expected window, an unreachable channel, or a dedup cap). Folding it back into `test_search_quality.py`'s N/25 recreates the exact rot it exists to prevent. `instrument.py` stays free of deepeval and of the network so a measurability question can be answered without the eval stack, and `IndexView.channels` is tracked separately from `chunk_seconds_by_video` because `ChannelCoverageMetric` is satisfied by ANY video from an expected channel - keying channel reachability on the expected video's presence false-alarms on a healthy channel whose one golden video was re-uploaded.
  6. **`golden_dataset.yaml` edits need ADR-grade justification per ADR-0017, and every one is recorded in the CHANGE LOG at the top of that file.** The #190 fix removed the dedup cap by changing the instrument, not the contract. The audit's one red - Q02, whose `video_id` `JDAIOSWfPn0` had left the corpus in a creator re-upload - was corrected on 2026-09-02 by swapping to `iG_CCjdyeX0` after re-verifying every timestamp_range against the transcript on disk (none needed to move; it is the same recording). **That correction is the strongest evidence the audit earns its keep: Q02's `recall_at_k` went 0.000 -> 1.000 on the id swap alone**, so the 0.000 it had been contributing to the headline N/25 was never a retrieval result. An audit red is a broken ruler mark to fix at the source, never a red to silence with a `skip`.
  7. **`build_test_case` records `vi._hit_video_key(h)`, never a bare `h.get("video_id")`.** Roughly 750 chunks in the live index carry a BLANK `video_id` and are separated by `source_file` instead - the selector's own fallback identity. Reading `video_id` alone collapses every identity-less video into one `""` slot, and because `k` and `rank` now count distinct videos, that collapse stops genuinely distinct videos from consuming a `k` slot or advancing MRR rank. The error direction is always INFLATION (measured: `RecallAt5` 0.000 -> 1.000 for an expected video sitting behind five identity-less ones), which is why a spot check looking for missing results would not catch it. Caught by an executing reviewer, not by reading the diff; it does not change any of the current 25 measurements, but it is one corpus change away from doing so. Test contract: `tests/evals/test_metrics.py::TestIdentitylessVideosDoNotInflateScores`.
  8. **A ceiling that disagrees with the metric it audits is worse than no ceiling** - it manufactures both false-measurable and false-unmeasurable verdicts. Three specific agreements to preserve: (a) `timestamp_precision_ceiling` runs a maximum-overlap sweep per video, because `TimestampPrecisionMetric` lets ONE chunk satisfy every window it falls inside - Q24 has two overlapping windows in one video that a single chunk reaches, so a naive one-window-per-video assumption would flag a measurable query as unmeasurable; (b) `recall_ceiling` is bounded by the query's own `k`, because `RecallAtKMetric` inspects only the top-`k` videos; (c) the channel branch is bounded by `harness_limit(gold)`, because the harness can never show more distinct channels than it returns videos. `harness_limit` is defined ONCE in `instrument.py` and imported by `test_search_quality.py` - a re-derived limit inside the audit is the checker-disagrees-with-writer class (the PR #136 entry above), where the audit predicts a run that never happens.
  9. **`IndexView.channels` distinguishes `None` (no projection supplied) from an EMPTY frozenset (projection ran, found nothing).** Folding them into one falsy test makes real channel-data loss look identical to an absent projection and silently false-passes the audit. The conftest fixture always passes a frozenset, and it asserts `arrow.num_rows == table.count_rows()` before building the view - `limit(0)` means "no limit" on lancedb 0.30.2 (verified: 80,297 == `count_rows()`), but a future version reinterpreting it as a page size would make the audit report healthy videos as "not in the index", which is the worst verdict this feature can produce. A silent partial projection must fail loudly instead.
  10. **`malformed_dimensions` reports an unknown dimension name rather than ignoring it.** `_build_metrics` reads dimensions with `.get`, so a misspelled key silently drops a GATING metric and the query then passes on whatever remains - the same invisible-cap shape one layer up. `KNOWN_DIMENSIONS` must stay in sync with `_build_metrics` plus the declared-but-deferred `position_diversity`.
- **The living docs are checked against the CLI mechanically (issue #204).** A read-only audit found four claims that would make an agent following the docs literally do the wrong thing - a model default two versions stale (in README, INSTALLATION, SKILL.md *and* the CLI's own `--model` help text), a chunk default that issue #157 lowered from 50 to 30 on purpose, an eval command this file explicitly forbids, and a broken ADR link. Every one was mechanically checkable and nothing was checking. `tests/test_docs_currency.py` is that check, and it is cheap on purpose (no Gemini, no network, no corpus read: `--help` exits before any side effect and the link check is a stat) because a guard that costs money to run stops being run. Five things to protect:
  1. **`LIVING_DOCS` is what a user or agent reads to decide what to run**; `docs/plans/`, `docs/adr/` and `docs/brainstorms/` are deliberately NOT swept, per the three-bucket rule. A plan that recorded the model of its day is correct as history and must never be rewritten to match today's constant.
  2. **The stale-model check has NO exclusion list, and that is the load-bearing detail.** The first cut skipped lines containing `measured`/`scorecard`/` vs ` on the theory that A/B prose legitimately names the model that lost - and that exclusion made the README's OWN model row immune, because the row cites the scorecards. Falsification caught it: the injected regression passed. Zero living docs need to name a superseded model, so the check is absolute. Narrowing it again requires re-falsifying.
  3. **The `--model` help string is documentation too.** It is built from `DEFAULT_MODEL` with an f-string now rather than a hardcoded literal, so it cannot drift; `test_the_cli_help_text_names_the_real_default` executes the real CLI rather than reading the source.
  4. **The parametrization has a companion.** `test_the_registry_is_not_empty` exists because a `add_parser` regex that stops matching turns the per-subcommand test into zero cases that pass forever - the same tautology class as the issue #182 field-inventory walk.
  5. **The link check earns its keep on exactly the move this PR made.** Extracting the 221-line BCS section from README into `docs/translate-bcs.md` broke five root-relative links inside it; the check caught all five before the PR opened. Any future section move between directories needs the same pass.
  6. **The skill COUNT is checked too, and a count alone is not enough.** The headline finding was a README section claiming TWO skills when three ship. The first cut fixed the "Plugin Contents" table and left an identical claim 600 lines later in "Cross-Platform Compatibility" - found by a standards reviewer, not by any test. `TestSkillCountCannotDrift` now pins the spelled-out count word against the real `skills/*/SKILL.md` count, and separately asserts every shipped skill is NAMED in both README and INSTALLATION - because fixing only the number left INSTALLATION's blurb reading "Three skills in one install" while still listing two by name. That test found three further stale claims (`README.md` migration note, INSTALLATION's blurb and its Agent Skills paragraph) that neither review layer had caught. Its gap regex allows only letters, spaces, hyphens and backticks between the count word and the noun, because a looser gap false-positived on "two-command summary; [`skills/translate-bcs/SKILL.md`]".
  7. **The chunk-default check scans a WINDOW, not a line, and that is a lesson not a detail.** The first cut required "chunk-minutes" and "50" on the SAME line; an accuracy reviewer found a live stale pair it missed, where prose wrapped so that "then the default (50)." and "(default: 50)." each sat on a line carrying no "chunk-minutes" text. The suite passed 24/24 over a real defect - a currency guard that reads one line at a time will miss exactly the wrapped prose it exists to police.
  8. **A test named for the docs -> CLI direction must actually read the docs, and the Codex peer pass caught that it did not.** `TestEveryDocumentedCommandParses` originally drew every case from `_registered_subcommands()` - the SOURCE registry - so a README that said `video_intel.py scna` failed nothing: the case list never came from the README. `_documented_subcommands()` now walks `LIVING_DOCS` for real invocations (skipping global flags like `--model` and their values), and the class runs BOTH directions: docs -> CLI (every documented command exists) and CLI -> parser (every registered subcommand's `--help` exits 0). They catch different things and neither substitutes for the other. `test_the_doc_extraction_is_not_vacuous` pins the hard instances by NAME (a bare command, one behind `--model`, and the two-word `profile show`), not merely a count - falsified by neutering the extractor's line filter.
  9. **The manifest check was a tautology: `plugin.json` has no `skills` key, so `manifest.get("skills", []) or self._skill_dirs()` compared `_skill_dirs()` with itself.** It branches explicitly now - no declaration means assert the manifest's key set is unchanged and return (so a manifest that STARTS declaring skills reaches the real comparison instead of silently taking the vacuous path), and a present `skills` list is compared against the filesystem. A diff that restores the `or` fallback restores a test that cannot fail. Same tautology class as items 2, 4 and 7, and as the issue #182 field-inventory walk - four instances in one file is why this rule keeps earning its place. Falsified by adding a disagreeing `skills` list to the manifest.
  Known deferred (its own issue, not this one): `config.get("default_prompt", ...)` falls back to `mindmap-light` at three call sites and `mindmap-knowledge` at three others, so no single doc claim about the code fallback can be unambiguously true. The docs now state what `config.yaml.example` ships (`mindmap-knowledge`), which is what a user who copies the template actually gets.
- **Manual `--url` channel resolution is ONE helper pair, never a private copy (issue #205).** Reported live: `transcript --url <video from an unconfigured channel>` died with `KeyError: 'url'`. The matcher walked every configured channel reading `ch["url"]` unconditionally, and an `enabled: false` placeholder for a non-YouTube source legitimately carries no `url` key - a documented, supported shape - so ONE such entry broke every manual run against an unconfigured video, crashing before `channel_name` could fall back to the slugified title or `_standalone`. The issue asked whether `cmd_mindmap` and `_cmd_process_url` shared the matcher; they did, as THREE byte-identical copies of the same six lines. **That duplication is the finding**, and a review pass then found the copying went one layer deeper than the fix: `_cmd_mindmap_impl` and `_cmd_process_impl` each carried a FOURTH and FIFTH inline copy of the adjacent `channel_config_by_name` lookup, both written `config.get("channels", [])` - verbatim the shape the new matcher's own comment calls out as the crash - so `mindmap` raised `TypeError` on `channels: None` while its two siblings survived. Both now route through the shared helper. Eleven things reviewers must protect:
  1. **`.get("url")`, never `ch["url"]`, inside the matcher.** `cmd_scan` keeps its own unconditional read and that one is correct: a SCANNABLE channel must have a url, and `_channel_scan_enabled` has already filtered the placeholders out - verified, including for a non-boolean `enabled` and `enabled: 0`. A url-less channel reaches that read only with `enabled` absent or true, i.e. a channel asserting it is scannable, which is a genuine config error.
  2. **Every skip is a `continue`, never a `break` OR an early `return`** - and that applies to all THREE skip reasons, not just the url-less one. The reported config has both placeholders ABOVE a real channel, so stopping early silently stops matching everything below it, which is a quieter bug than the crash it replaced. The nameless-match branch was a `return None` - a `break` in disguise, reproduced: nameless-first, real-second returned None.
  3. **The YouTube-shape check runs BEFORE the API call**, reusing issue #113's `_is_youtube_channel_source` rather than re-deriving it. Standing probe-before-you-pay rule.
  4. **A lookup that raises degrades to "no match", never a traceback.** Convenience lookup; `_standalone` is always available. Verified at all three callers: a mid-walk `quotaExceeded` on channel 1 still resolves channel 4.
  5. **`config.get("channels") or []` AND `isinstance(c, dict)` are two guards for different shapes, and both must stay pinned separately.** `channels:` with nothing under it parses as `None`; `channels:` written as a mapping (dashes omitted), given a bare string, or carrying a scalar list entry (`- alpha` for `- name: alpha`) each raised `AttributeError` on `c.get`. The review found that removing either guard ALONE left the suite green - only removing both failed anything - so each now has its own test. All four shapes are ordinary YAML mistakes, and a malformed watchlist must degrade to "no configured channel", never abort a run started to transcribe one video.
  6. **The coverage must be CALLER-level, and a source-count guard is not a substitute.** This is the sharpest lesson of the ticket. The first cut's 21 tests all called `match_configured_channel` directly; a reviewer reverted `_cmd_process_url` to the verbatim pre-fix inline loop using a different loop variable (`entry["url"]`) plus one decoy mention to keep the name count at four, and **all 21 stayed green while the real CLI raised `KeyError: 'url'`** - the exact bug the ticket is about, live, under a green suite. Both drift guards are structurally blind to it: one greps the literal substring `ch["url"]`, which any other loop variable evades, and the other is a raw `source.count("match_configured_channel(")`, which a future code comment containing parentheses would restore. Three caller-level tests per command now drive the real `cmd_transcript` / `cmd_mindmap` / `cmd_process` with only the YouTube client stubbed, and the capture records EVERY positional and keyword argument rather than guessing which one carries the channel - a capture that guesses wrong records `None`, which reads as "the match failed" and would make the test lie in the safe direction.
  7. **Quota, stated accurately - and the gate also changes ROUTING, not just cost.** The walk stops at the match, skips url-less and non-YouTube entries, resolves a bare `UC...` id with no call, and caches within one call: on a 74-channel config a MATCHED video costs 2 calls where it cost 74, while an UNMATCHED video still costs ~72, because every YouTube channel must be resolved to prove none owns the video. Persisting resolved ids across runs would fix that too and is deliberately out of scope (it needs a cache-invalidation design; a channel can be renamed). **The routing change is the part the first version of this entry missed:** `get_channel_id` submits the last path segment for ANY host, so the old walk could MATCH on a non-YouTube url whose last segment happens to be a real YouTube handle. Four live-config entries are now gated out - `earlyaidopters` and `tech-snack` (skool.com), `kieranklaassen` (x.com), `dudley` (a bare path) - and `_YOUTUBE_HOSTS` also excludes `music.youtube.com` and a bare `@handle`. For any of those, a manual `--url` run that used to land in the configured folder now lands in a new slugified one, splitting artifacts and losing that channel's `transcript_source` / `chunk_minutes` / `mindmap_source` knobs on the manual path (issue #127). The new behavior is more correct - the old match was accidental and cost quota - but `--channel <name>` is the workaround and this is a behavior change, not only an optimization.
  8. **A duplicate channel NAME is warned about, and the residual is recorded rather than pretended away (Codex peer pass).** The matcher matches a row by URL but returns its NAME, and `channel_config_by_name` then re-finds the FIRST row carrying that name - so a video from the second row's channel lands in the right folder while silently inheriting the first row's `transcript_source` / `chunk_minutes` / `mindmap_source`. That is issue #127's failure class arriving through a different door, reproduced by Codex. It WARNS rather than raising, and the warning does NOT make the lookup correct: the name IS the output folder, so two rows sharing one are already writing to the same place and the run is still the best available answer. A test pins the residual (the first row still wins) precisely so a future reader cannot infer from the warning that the wrong-knobs case was fixed. The real fix is to preserve the matched ROW's identity rather than round-tripping through a non-unique display name, which is a larger change than the crash this ticket is about.
  9. **"Could not establish identity" is not "identity is absent" (Codex peer pass).** Invariant 4's `except Exception` is right - one bad channel must not kill the run - but a REQUEST-WIDE failure (`quotaExceeded`, a bad key) makes EVERY lookup fail, and the matcher then returns `None`, which the caller reads as "unconfigured": it slugifies the video's title into a brand-new folder and drops that channel's routing knobs. The walk records that a lookup raised and, on an unmatched result, logs one line naming `--channel <name>` as the certain route. Still no raise. **The companion test matters as much as the warning**: a genuine no-match must NOT carry it, or the line fires on every legitimately-new creator and is ignored within a week.
  10. **The hardening is LOCAL to the manual `--url` path, deliberately.** Codex confirmed by execution that `collect_headline_channels` still raises on all four malformed-`channels` shapes, and that scan, status, concepts, profile inference, dedupe and prune-shorts carry the same unguarded comprehensions. That is a corpus-wide sweep, not this ticket - tracked separately. Do not read the guards above as protecting the whole script.
  11. **The bare-`UC...` short-circuit skips existence verification.** Old code returned `None` for a dead UC id; the new one returns it verbatim. It cannot produce a WRONG match - a live video's `channelId` is by definition live - so it is safe, and it is recorded only because the A/B shows the divergence. Test contract: `tests/test_manual_url_channel_matching.py`.
- **Skill-parity: same diff, not follow-up.** When a PR adds a new CLI subcommand or flag to `video_intel.py` or `translate_video.py`, the matching `SKILL.md` entry (natural-language routing) lives in the same PR. "I'll update the skill separately" is a regression — the plugin's skill surface drifts from its CLI surface and users can't reach the new capability through the skill.
- **Paraphrase verification uses `search --vector`, not `Grep`.** The speaker's vocabulary almost never matches a paraphrase verbatim; keyword search returns false negatives. A user prompt like "verify whether [creator] said [X]" or "is this quote real" routes to the `video-intel-search` skill's hybrid-search command. Reviewers: grep for direct corpus `Grep`/`Read` calls in any verification-shaped session - those are bugs.
- **Video id is the identity, slug is decoration.** Any code that dedups, idempotency-checks, or looks up per-video artifacts must key on `video_id` (with slug as fallback for legacy files missing meta.json). The 2026-04-22 title-rotation dedup shipped `_load_video_id_index()` and a `dedupe` subcommand precisely because slug-only checks missed A/B-tested titles. Reviewers: grep for `video_file_prefix` or `is_processed` in diffs touching scan/transcript/concepts — any new path that treats slug as identity needs pushback.
- **Every writer that REPLACES a transcript owns every field that describes that transcript (issue #182).** `update_meta` merges, so a writer that sets only its own fields leaves the previous writer's metrics describing an artifact that is gone. `_try_captions_transcript` was the only writer doing this: a captions recovery after a flagged Gemini attempt produced a meta reading `transcript_status: complete`, `transcript_source: youtube_captions` and `transcript_quality_flags: ["monolithic_severe"]` at once. Measured cost on the live corpus: four videos each paid for mindmap-from-video (up to 411k prompt tokens) with a healthy transcript sitting beside them, because `resolve_mindmap_source(..., transcript_severe=True)` correctly treated the stale flag as real - plus a permanent `EXIT_PARTIAL` and a `dedupe` ranking penalty each. This is the exact inverse of the #159 "flag laundering" hazard: there a severe artifact was made to look clean, here a clean artifact was made to look severe. Six things reviewers must protect:
  1. **`TRANSCRIPT_ARTIFACT_FIELDS` is the inventory, and `update_meta`'s `drop_fields` is how a writer retires what it does not own.** Dropping is explicit and opt-in - a blanket "clear every `transcript_*` key" sweep would also erase `transcript_quality_note`, which is the operator's own hand-written annotation.
  2. **The captions path ASSESSES rather than merely clearing.** Clearing was the minimal correct fix; assessing keeps the guarantee that every transcript on disk has been judged. A genuinely bad caption track - five cues over three hours - is exactly what the #157 machinery exists to catch, and clearing alone would have exempted captions transcripts from it forever.
  3. **The assessment runs on `dedup_caption_cues`, not the raw track.** Rolling-window ASR repeats cues and the body collapses them to one per second, so assessing the raw track would count entries the transcript does not contain. Same checker-must-use-the-writer's-own-output rule as the PR #136 path class.
  4. **A clipped segment is assessed against its OWN span, and the offsets are treated as UNTRUSTED.** `--start`/`--end` against a whole-video duration would read as one enormous blind gap and flag every segment severe - but the offsets are operator input, validated nowhere upstream, so a window WIDER than the real content manufactures the same false severe from the other direction. Codex's concrete case: 10 cues over a real 20 minutes is a healthy 0.5/min, but `--end 02:00:00` assesses them over 120 claimed minutes at 0.083/min and trips `monolithic_severe`. `_captions_assessment_window` clamps an explicit end to a KNOWN duration (the operator meant "to the end", imprecisely) and returns `None` - falling back to duration-based or metrics-only assessment, never to a fabricated span - for a negative offset (`-1` is truthy, so a plain falsy check lets it through and manufactures a leading gap), a reversed window, or a start at or past the duration.
  5. **`duration_seconds` is threaded from every one of the TEN call sites**, and `None` genuinely means unknown - the assessor then skips gap and density rather than inventing a verdict, so a dropped argument does not fail loudly, it silently downgrades that path to metrics-only and stamps a five-cues-over-three-hours track `complete`. The first cut of this fix missed the `_cmd_process_url` chunked-failover site while claiming nine, and no test caught it because every test called the writer directly and handed the duration in. `tests/test_captions_quality_flags.py::TestEveryCallSiteThreadsTheDuration` AST-walks the module instead, with a companion test asserting the walk still finds the sites at all so it cannot pass vacuously.
  6. **The field inventory cannot drift, and the test that proves it must not be a tautology.** `TestFieldInventoryCannotDrift` collects every `transcript_*`/`captions_*` string used as a DICT KEY anywhere in the module and requires each to be in `TRANSCRIPT_ARTIFACT_FIELDS` or in an explicit, reasoned `NOT_ARTIFACT_FIELDS` list. An earlier version walked only constants appearing SYNTACTICALLY INSIDE an `update_meta(...)` call - but the chunked and salvage writers build `meta_fields = {...}` as a variable and pass the name, so their keys were invisible: the walk saw 7 fields, all from the two inline dict literals, i.e. the captions writer compared against itself. That tautology is why four real fields (`transcript_confabulation_note`, `transcript_recovery`, `transcript_parse_error`, `transcript_warning`) were missing from the inventory while the suite stayed green. A companion test asserts the walk actually sees the variable-built writers.
  7. **A LEADING blind gap in a caption track is MILD, not severe.** A caption track is ASR ground truth about SPEECH - silence before the first cue means nobody had spoken, not that a model skipped content, which is what a leading gap means in a Gemini transcript. A livestream pre-show is the common shape and issue #120 routes that population here on purpose. Measured across all 83 captions transcripts in the corpus: 0 currently exceed the 600s threshold, but the two largest leading gaps are **576s and 558s**, both livestream Q&A/AMA videos, both within 4% of the line - a near-miss, not a theoretical risk, and a false severe here costs exactly what #182 was filed about. Deliberately LEADING only: an internal hole could be a music segment or a genuine caption failure and the evidence does not separate them (largest observed internal gap 243s); monolithic collapse is untouched. Widening it needs its own evidence.
  8. **The rule is universal, so it is universally applied.** `retired_transcript_fields(fields)` is the one expression of "the inventory minus what I am writing", and all four transcript writers pass it - captions, single-shot success, salvage, and chunked. Four hand-maintained lists would drift; one helper cannot. `TestTheRuleAppliesToEveryTranscriptWriter` AST-walks for any `update_meta(..., "transcript")` that omits `drop_fields`. Without this, a video recovered via captions and later re-run successfully under Gemini kept `captions_is_generated: true` on a full multimodal transcript - the same lie in the opposite direction, and "which of my videos are speech-only captions transcripts" is precisely the sweep that produced this issue.
  9. **A severe caption track is visible where a reader will see it.** The markdown is written AFTER the verdict so it carries the same warning block every other partial-producing path writes, and the writer returns `"partial (captions quality guard)"` rather than `"done (captions)"` - #157 invariant 6, so a caller reading the status string rather than the persisted meta can tell a demotion from a clean success. Test contract: `tests/test_captions_quality_flags.py`.
- **Every transcript meta write stamps full identity.** Issue #66: the single-shot and captions transcript writers used to persist `{processed, transcript_status, ...}` with no `video_id`, and since the transcript loop is the first writer (inverted ordering #54) it left identity-less metas that `_load_video_id_index` skips — breaking idempotency (re-transcribed every scan; a re-queued hang then froze the scan). All three transcript meta writers (single-shot success, salvage, captions failover) MUST merge `_transcript_identity_fields(video, channel_dir)` — `{video_url, video_id, channel, title, published}`, matching the chunked path's `meta_fields` shape. Reviewers: any `update_meta(..., "transcript")` call in `process_transcript`/`_try_captions_transcript` that does not spread `_transcript_identity_fields` is a regression. `repair-metas` backfills pre-existing identity-less metas from the `.transcript.md` header (Source URL → `video_id`); it must never overwrite an existing field or fabricate identity for a non-YouTube source. Test contract: `tests/test_identity_meta.py`.
- **Shorts identity is `duration < 60s OR /shorts/<id> HEAD returns 200`.** Do not substitute aspect-ratio detection — YouTube Data API does not reliably expose aspect ratio. Do not substitute `#shorts` hashtag sniffing — user-editable, unreliable. The `/shorts/` redirect is YouTube's own classifier (the same signal yt-dlp uses). Empirically YouTube returns **303** (not 302) for non-Shorts; tests should assert "non-200 → False" rather than locking in a specific redirect code. Reviewers: grep for `is_short` or `_is_youtube_short_url` in any diff touching scan or `prune-shorts` — any new path using a different signal needs pushback.
- **Prune-shorts deletion uses an explicit suffix allowlist, not a wildcard glob.** `PRUNE_SHORTS_DELETION_PATTERNS` enumerates the artifacts to delete. The dedupe pattern of `channel_dir.glob(f"{prefix}.*")` is deliberately **not** mirrored here because translate_video.py produces `.en.srt` and `.translate-bcs.txt` siblings sharing the prefix that must survive. Reviewers: grep for `PRUNE_SHORTS_DELETION_PATTERNS` in any diff touching `_apply_prune_shorts` — any switch to whole-prefix glob deletion needs pushback. The regression test `test_apply_preserves_translate_bcs_sidecars` in `tests/test_skip_shorts.py` locks this contract in place.
- **Dedupe's canonical selection must re-use `transcript_quality_flags_are_severe()`, never re-implement severity (issue #159; hardened by a dual-review pass the same issue).** `_pick_canonical`'s ordering is (1) not-severe beats severe, (2) within one severity bucket the pre-#159 tie-break (latest `processed`, then `modes_completed` size, then alphabetical prefix) is unchanged - both-severe and both-clean groups stay byte-identical to the pre-#159 ordering. `_dedupe_meta_is_severe` is a thin wrapper: a `transcript_quality_flags` field that isn't a `list` (e.g. a bare string) degrades to "not severe" before the call, but the actual severe/mild classification is delegated entirely to the shared helper - a hand-rolled `"severe" in flags` re-derivation is a regression, since it would drift from the #157/#158 severity set the moment that set changes. **The shared helper itself is now order-independent and entry-safe**: `transcript_quality_flags_are_severe` filters to string entries (`if isinstance(f, str)`) before the frozenset membership test, so a malformed list like `[{"x": 1}, "monolithic_severe"]` can no longer raise on the unhashable dict before `any()` reaches the genuine severe string a few entries later (pre-fix, the verdict depended on which side of a bad entry the real flag landed on) - this closes the gap for every caller, including `_transcript_quality_severe_from_meta`, which does its own read with no wrapper-level coercion. Reviewers: grep for `_dedupe_meta_is_severe` and `transcript_quality_flags_are_severe` in any diff touching either. **Provenance follows the moved artifact ("flag laundering", `_mode_provenance_fields`)**: when the flip demotes a severe meta to loser and its mode's artifact moves onto the canonical prefix, the canonical meta must inherit that mode's per-mode provenance fields from the loser (the enumerated `transcript_*`/`mindmap_*` fields plus, for transcript mode, a generic sweep of every other `transcript_*` key present) - copy only keys that exist, never invent one. Silently keeping the canonical's own absent-or-clean provenance would launder a severe transcript into looking healthy under the survivor's identity. **A meta's `modes_completed` claim is verified against disk before computing what's "missing" (`_modes_present_on_disk` / `_verified_modes_completed`, using the writer's own `_MODE_ARTIFACT_PATTERNS`, never a re-derived path)**: an operator can delete an artifact by hand without editing the meta, and trusting an unverified claim can both under-count what's missing (deleting a loser's only real copy of a mode) and leave `modes_completed` claiming a mode with nothing behind it. Only a mode `_move_missing_mode_artifacts` actually moved credits `modes_completed` or triggers a provenance copy - a claimed-but-absent artifact on the loser's side moves nothing and is never invented either. A destination collision during a move (`dst.exists()`) now logs a WARNING naming both paths instead of silently skipping. `cmd_dedupe`'s dry-run log lines carry each meta's `[clean]`/`[severe]` standing so a quality-driven flip is auditable before a destructive `--apply`. **Scope note**: status-based demotion (e.g. `partial`/`truncated_output` with no severe flag) was considered and deliberately excluded from #159 - severity flags only; do not implement status-based canonical demotion without a fresh issue. Test contract: `tests/test_video_id_dedup.py` (the `_pick_canonical` cases, the disk-verification and provenance end-to-end cases, the move-skip-warning case, the dry-run-severity-marker case) and `tests/test_transcript_quality_guard.py::TestSeverityIsOrderIndependentAndMalformedEntriesDegrade`.
- **Out of scope for cleanup flags.** `docs/plans/*.md`, `docs/solutions/*.md`, `docs/reports/**` (markdown and generated HTML), `work/**/*`, and the root `plans/` directory are living or historical session artifacts. Review agents must not flag them for deletion, rewriting, or consolidation — that's the three-bucket rule at the end of the Workflows section.
- **Skip is per-mode now (`skip_modes` array), not a boolean.** As of issue #42, the meta.json contract is `skip_modes: ["transcript"]` (or any subset of `mindmap | transcript | concepts`). Legacy `skip: true` is honored as full-skip **only** when `skip_modes` is absent - if both keys exist, `skip_modes` wins outright. All call sites that gate on skip must go through `is_skipped(..., mode=<mode>)` (disk path) or `is_skipped_meta(meta, mode=<mode>)` (in-memory). Reviewers: grep for `meta.get("skip")` / `existing.get("skip")` / `existing_meta.get("skip")` in any new code - those are leftovers. The only legitimate bare read is inside `is_skipped_meta()` itself. *Why:* a 2h 24m Sean Kochel video burned 6.5 hours of scan wall-clock when the transcript path silently truncated; the recovery (manual `skip: true`) was too coarse and also blocked the concepts pass that the existing mindmap could have fed.
- **Long-video transcript guard runs in `cmd_scan`'s transcript loop, not the mindmap loop.** `transcript_max_duration_seconds` (top-level config, default 7200 = 2 hours) filters videos out of the auto_transcript candidate set after `enrich_with_durations()`. Mindmap stays unaffected because mindmap output is small and never truncates. Reviewers: any change that moves the threshold check above the mindmap loop, or removes the WARNING-with-recipe log line, breaks the user's recovery path. Unparseable durations (`_parse_iso8601_duration` returns None) must fail-safe to KEEP the video - silent drops are worse than a visible truncation. The regression test `test_video_with_unparseable_duration_kept_fail_safe` in `tests/test_skip_long_videos.py` locks this in.
- **Per-transcript timeout uses a daemon thread, never `ThreadPoolExecutor` or `signal.alarm`.** `_run_with_timeout` (issue #74) wraps each transcript Gemini call (single-shot + per chunk) and raises `TranscriptTimeout` on expiry so a hang routes to the captions failover instead of deadlocking. It MUST stay a daemon `threading.Thread`: `ThreadPoolExecutor.shutdown` re-joins the still-hung worker (re-deadlocking at the executor boundary), and `signal.alarm` is Unix-only (this runs on Windows). The orphaned worker is acceptable precisely because it is a daemon - it never blocks process exit. `TRANSCRIPT_TIMEOUT_DEFAULT` (600s) must stay BELOW `create_client`'s httpx `read` timeout (1200s) so the wall-clock fires first. Reviewers: any switch to a non-daemon executor, a Unix-only signal, or a timeout above the httpx read timeout reintroduces the hang-deadlock. Test contract: `tests/test_transcript_timeout.py`.
- **`skip_video_ids` is the reactive cost-saving primitive; runs pre-enrich.** Per-channel `skip_video_ids: [<id>, ...]` (issue #42 follow-up) is the declarative, pre-fetch blocklist. Filtered in `cmd_scan` AFTER `fetch_*_videos` and BEFORE `enrich_with_durations`, `record_alt_title_if_rotated`, and any Gemini call. Reviewers: any change that moves this filter to AFTER enrich (or to AFTER `is_processed`) costs the user a YouTube API call and sometimes a Gemini call per blocklisted ID. The regression test `test_blocklist_skips_enrich_for_listed_ids` locks the ordering. The user's mental model is reactive (mark IDs after observing failure), not predictive - do not propose auto-populating `skip_video_ids` from threshold-exceeding videos; the user's own taste for "long" varies and the filter is intentionally manual.
- **Headline digest (`headline_digest: true`) is a bounded, reversible reporting feature - never a discovery framework (issue #113).** Peripheral vision over YouTube channels the user does not actively follow, rendered as a trailing "Other headlines" section of a full `scan`. Five load-bearing invariants reviewers must protect:
  1. **`headline_digest` is a separate opt-in boolean, never an `enabled` tri-state.** Do NOT re-collapse it into `enabled: true | false | "headlines"`. The scan gate `_channel_scan_enabled` is a STRICT boolean (a non-boolean `enabled` is treated as disabled, never truthy-admitted): a string like `"headlines"` is truthy and a naive `c.get("enabled", True)` gate would silently pull it into full Gemini processing - the exact bug the separate key avoids. Test contract: `test_non_boolean_enabled_stays_out_of_primary_loop`, `test_channel_scan_enabled_helper`.
  2. **Eligibility requires a validated YouTube source checked BEFORE `get_channel_id()`.** `collect_headline_channels` runs `_is_youtube_channel_source` (exact host match against `_YOUTUBE_HOSTS`, or a `UC...` id) itself, so a mis-flagged non-YouTube url (Skool, Vimeo) never reaches the API - `get_channel_id()` submits the last path segment to the YouTube API for any host. Test contract: `test_collect_never_calls_get_channel_id_for_non_youtube`.
  3. **The headline path makes NO Gemini calls and writes NO corpus artifacts** (no meta.json, no concepts.json, no mindmap/transcript). It uses only the cheap uploads-playlist path + `enrich_with_durations` for the Shorts filter. Any Gemini call or corpus write in this path is scope creep.
  4. **Ranking uses `rank_headlines` (title/profile match), never `rank_unseen`.** Headline videos are metadata-only, so `rank_unseen` (concept-based, reads `concepts.json`) would score every one zero and collapse to pure recency. `rank_headlines` matches normalized title phrases against profile interest concepts (taxonomy labels/aliases where available) + domains. Test contract: `test_rank_headlines_scores_by_title_match`, `test_rank_headlines_positive_before_zero_score_recents`.
  5. **Profile is loaded with `load_interest_model()` (issue #115; never persists); seen-state is `_headlines/seen.json`, advanced ONLY after a non-dry-run render - never briefing `video_ids`.** A scan must never create/overwrite `profile.yaml` as a side effect, and headline videos are not corpus artifacts so their seen-set is distinct from briefings. `--dry-run` renders but does not advance the seen-set. The digest is skipped on focused `scan --channel X` runs (full-scan concept) and runs LAST, after the failure summary, wrapped so a headline-quota failure is non-fatal. Test contract: `tests/test_headline_digest.py` (`test_render_does_not_create_or_modify_profile`, `test_dry_run_does_not_advance_seen`, `test_render_advances_seen_and_does_not_resurface`, `test_focused_scan_skips_headline_digest`, `test_seen_state_is_bounded`). Do NOT add a standalone `headlines`/`digest` subcommand in v1 - the fetch/rank/render is factored into `render_headline_digest` so a later add is trivial, but adding it now enlarges the CLI + skill-routing surface before usage proves the need.
- **One compiled interest model ranks BOTH personalized surfaces (issue #115).** `compile_interest_model()` is the only place a profile's `interest_concepts` / `interest_domains` are interpreted, and `load_interest_model()` is the only path either consumer loads through (the old `infer_or_load_profile` was deleted in the same PR: a dead second writer whose default was `persist=True` is exactly how rule 1 gets undone): `rank_unseen` (briefings, concept evidence from `concepts.json`) and `rank_headlines` (headline digest, title/metadata evidence via each concept's `preferred_label`/aliases). Reviewers: any new call to `_coerce_profile_interests` or a hand-rolled read of `profile.yaml` inside a ranking path needs pushback - that is how the two surfaces drift apart about what interests the user. Eight load-bearing rules:
  1. **`profile init` is the ONLY writer of `_briefings/profile.yaml`.** `briefings --unseen` (dry-run or not) and the scan headline digest both rank from an ephemeral inferred profile when none is persisted and write nothing. A `persist=True` reintroduced into either consumer regresses this.
  2. **Neither `profile.yaml` nor `audience.md` is ever overwritten** - including a partial, empty, or malformed file. Hand-editing is the retune path; a broken file is still the user's file.
  3. **`profile show` has zero filesystem side effects** - no mkdir, no write, no persist. Test contract: `test_profile_show_reports_inferred_source_and_writes_nothing` snapshots the tree.
  4. **Personalization reorders, never deletes.** A low, zero, or **negative** score ranks lower; it never excludes. `_select_headline_items`'s remainder bucket is `score <= 0`, not `== 0` - with `== 0` a negatively-weighted item matched neither bucket and was silently dropped (and, never entering the seen-set, re-fetched and re-dropped every run). Zero-score items keep rendering on both surfaces (briefings tail, digest "Other headlines") and over-cap items stay unseen for the next run (#80/#88 rolling-cap contract). Every ranked item keeps its click-through link (`&t=` where known) - no rank without provenance. Non-finite weights (NaN/inf) are rejected at the single coercion point in `_coerce_profile_interests`: a NaN score sorts to the top and then matches no bucket at all.
  5. **One phrase is one piece of evidence.** `rank_headlines` pays each matched phrase once, at the highest weight among the concepts claiming it, and each concept at most once. Taxonomy aliases are shared across concepts (on the live corpus "Context Management" is an alias of three), so a per-concept sum let one generic phrase in a title collect several full interest weights - inflating the vaguest headlines, and only on the text surface (the concept surface counts a video's own concept ids, which cannot collide this way). Reviewers: a diff that reverts to `score += concept.weight` inside a plain `any(phrase in title)` loop reintroduces it. Test contract: `test_one_shared_alias_is_paid_for_once_not_once_per_concept`, `test_distinct_phrases_still_stack`.
  6. **Never spend irreversible state on provably blind ranking.** `load_interest_model` sets `taxonomy_ok=False` when `taxonomy.json` exists but will not parse, and `render_headline_digest` returns early on it. A corrupt taxonomy also empties an *inferred* profile, so every headline would score 0, five recents would be rendered and marked seen, and after the taxonomy is repaired those videos could never be surfaced ranked again. Skipping costs one digest; rendering costs those videos permanently. The briefings surface deliberately does NOT skip - it degrades to concept-id matching and writes nothing irreversible. Test contract: `test_corrupt_taxonomy_skips_the_digest_instead_of_burning_seen_state`.
  7. **`profile init` refuses to persist an empty profile.** With no `interest_concepts` to infer (usually: `taxonomy-build` has not run), persisting would make `{}` the permanent "persisted" profile - never overwritten, cold-start warning suppressed, every surface scoring 0 with nothing explaining why. It prints the `taxonomy-build` next step and still scaffolds `audience.md` (prose does not depend on weights). Test contract: `test_init_refuses_to_persist_an_empty_profile_but_still_scaffolds_audience`.
  8. **Popularity is not corroboration, and base rates stay visible.** Do NOT add cross-channel mention frequency as a ranking feature (ten reaction videos to one tweet is one source), and do not remove volume-context tables (e.g. `burst_report`'s) to tidy a surface. Source-trust / independence / credibility weighting is deferred until an observed manipulation or echo-cascade case in the corpus; `scripts/sdsm_network.py` already measures creator independence when that day comes.
  Also out of scope by decision: `profile edit`, merging the two files, migrating/deprecating `audience.md`, an LLM interview to build the profile, auto-tuning from feedback, and embeddings/LLM ranking. Test contract: `tests/test_personalization_profile.py`.
- **Chunked-transcript voice dedup is by name, not voice integer.** Issue #50's `merge_chunked_transcripts` keys on `(chunk_idx, original_voice) -> name -> global_voice` because Gemini renumbers voice ids independently per chunk. Reviewers: any change that switches dedup to bare voice integer (e.g. `seen_voices = set()`) silently merges different speakers from different chunks into one. The regression test `test_voice_collision_across_chunks_resolved_by_name` locks this: chunk 1 voice=1=Lex, chunk 2 voice=1=Peter must produce two distinct global voice ids. The coverage table at the top of the stitched `.transcript.md` is the user-visible signal that chunking happened - removing it on a "cleanup" pass breaks the user's ability to audit per-chunk failures.
- **A malformed Gemini entry must never crash an already-paid transcript call, on EITHER merge layer, and not just inside the merge functions themselves.** Issue #161 hardened `merge_transcript_json` (the final merge-time guard, single-shot and post-chunking) so one bad entry - missing/blank `start`, a non-hashable `voice`, a non-dict item inside a task list - is skipped rather than raising. Issue #171 closed the same hole one layer earlier, in `merge_chunked_transcripts` (which runs BEFORE `merge_transcript_json` on the chunked path, and long videos are exactly the ones that chunk): a non-LIST task value there used to iterate character-by-character into an `AttributeError` on `.get()`, and a non-dict entry inside a real list crashed on `dict(t)`/`dict(sc)` before the final guard ever got a chance to look at it. Both layers now share ONE convention, not two: `_usable_task_list(raw_json, key, note_sink=...)` rejects a wrong-typed whole task value (its `note_sink` parameter is issue #171's addition - `None` preserves `merge_transcript_json`'s original immediate-warning behavior byte-for-byte; a list lets the chunked caller aggregate instead of warning once per chunk), and an `isinstance(entry, dict)` guard mirroring the one already inside `_usable_timestamp`/`_usable_voice_id` catches a non-dict entry inside a genuine list. `_log_skipped_entries` is generalized the same way (a `caller` prefix + a label that can be a composite `"chunk N entry M"` string, not just a bare index) rather than duplicated. **A first-pass version of this fix shipped with the merge functions guarded but two separate, EARLIER real-path consumers of the exact same malformed `parsed` dict left unguarded inside `_run_chunked_transcript_url`, reachable before `merge_chunked_transcripts` ever runs - a caller-level review round is what found both, function-only coverage did not.** (1) The coverage-table `speaker_names` build reads `parsed.get("speakers", [])` directly - fixed the same way, `_usable_task_list(parsed, "speakers", note_sink=[])` + `isinstance(dict)`, with a throwaway `note_sink` because `merge_chunked_transcripts` processes the same `parsed` dict moments later and is the one authoritative place that logs the warning. (2) `_assess_chunk_coverage` read `parsed.get("transcripts") or []`, which only substitutes `[]` for a FALSY value - a truthy non-list SCALAR (`int`, `float`, bare `True`) sailed through and crashed `for entry in transcripts` with `TypeError: <type> object is not iterable` a few lines later inside `assess_transcript_artifact`, a DIFFERENT crash shape from the string-iterates-to-chars one. Reviewers: **any new code added to `_run_chunked_transcript_url`'s per-chunk loop that reads `speakers`/`transcripts`/`screen_content` off the freshly-parsed chunk dict, BEFORE the `merge_chunked_transcripts` call, needs the same `_usable_task_list` guard - do not assume the merge layer being guarded is sufficient**, and prefer a `note_sink=[]` there over a second independent warning for the same value. Eight things to protect in total: (1) `merge_chunked_transcripts` warns ONCE per task list for the WHOLE call, aggregated across every chunk - not once per chunk - because a systematically malformed multi-chunk response would otherwise emit up to 3-per-chunk warnings; each reported entry carries its 1-based chunk index (`"chunk 3 entry 7"`) since a bare list index is meaningless once entries from different chunks are aggregated together. (2) A malformed entry is SKIPPED in the chunked merger, never passed through - `dict(t)` is the copy step, so there is no way to carry an unusable entry forward even in principle (unlike `merge_transcript_json`, which has that option and does not take it either). (3) A skipped entry must never touch the issue #158 window-violation counters (`classified_dialogue`/`out_of_window`/`unparseable`) - it was never classified, so it must not enter any of the three; a chunk with malformed entries could otherwise produce a false `chunk_window_mismatch_severe` (the severity rule is a FRACTION of classified entries). **This cuts both ways and is intentional, not a residual bug: dropping malformed entries shrinks the denominator too, so a chunk that lost most of its entries to malformation can legitimately reach `chunk_window_mismatch_severe` on very few surviving stamps (e.g. 2 of 22) - a chunk that lost 20 of 22 entries to malformation IS genuinely degraded, and the severity math is meant to see exactly that** (`tests/test_chunked_merge_malformed_entries.py::TestDroppedMalformedEntriesShrinkTheSeverityDenominatorOnPurpose` locks the shape in). (4) A malformed `speakers` entry in the chunked merger is gated on a plain `isinstance(s, dict)` check, deliberately NOT routed through `_usable_voice_id` itself - that predicate also requires a hashable `voice`, which is stricter than this loop's pre-#171 contract (a speaker with `voice=None` was always still added to `merged["speakers"]` via its name; only the remap step was skipped), and reusing it here would silently drop speakers the function has always kept. (5) A WHOLE task list being the wrong type gets its OWN warning (`_log_whole_task_list_drops`), separate from `_log_skipped_entries`'s per-entry aggregate - folding the two together used to under-report a chunk's entire dialogue going missing as "skipped 1 malformed entry," which reads, to a `#172`-style bulk-sweep operator grepping logs, as a single bad line item rather than "this chunk transcribed nothing." (6) `name`/`voice` fields read off raw JSON and used as dict keys or membership-test operands are guarded by `_is_unhashable_json_scalar` (JSON has exactly two unhashable shapes - `dict` and `list` - every other JSON value is hashable) at FOUR sites, not the one #161 already covered: `voice_remap[voice] = ...` and the `name_to_global` membership test/write in the chunked speakers loop, `t.get("voice") in voice_remap` in the chunked transcripts loop (a TRANSCRIPTS entry's `voice` is never validated by `_usable_voice_id`, which only ever runs on SPEAKERS entries), and `merge_transcript_json`'s own transcripts loop, which normalizes an unhashable `voice` to `None` AT COPY TIME so it renders through the pre-existing `"Speaker None"` default rather than guarding the render call itself. An unhashable `name` has no usable key to store a speaker under at all and is skipped like any other unusable entry; an unhashable `voice` on an otherwise-usable speaker keeps the speaker under its name (exactly like the pre-existing `voice is None` case) and only skips the remap step. (7) Warning-message assertions in this file's test contract check the FULL deterministic message text (or a value computed independently the same way `_entry_snippet` computes it), never a bare `"chunk 3" in message` substring - a substring check does not lock the count or the caller prefix, and this repo has been bitten by that weak-assertion pattern before. (8) The three fixes at the top of this bullet plus the two caller-level ones must all be provable at the REAL CALLER (`_run_chunked_transcript_url` with only the Gemini call stubbed), not the merge functions alone - a stub-only test suite passed 76 tests over a genuinely crashing production line in the first review round of #171. Test contract: `tests/test_merge_malformed_entries.py` (the single-shot/final-guard layer, including `TestUnhashableVoiceOnATranscriptsEntryIsGuarded`) and `tests/test_chunked_merge_malformed_entries.py` (the chunked layer - `TestCallerLevelCoverageTableSpeakerReadDoesNotCrash` covers all three task lists x {whole-list wrong type, non-dict entry} = six cases through the real caller, `TestAssessChunkCoverageNonIterableTranscriptsScalar` and `TestUnhashableVoiceAndNameFieldsDoNotCrashTheChunkedMerger` add the caller-level proof for the two follow-up crash classes). **The SAME `parsed.get(key) or []` / `.get(key, [])`-unguarded-scalar hole exists on the SINGLE-SHOT path too, not just the chunked one** - `_dialogue_entries_from_raw_json` and the parse-shape observability `log.info` call inside `process_transcript` both used to crash on a truthy non-list scalar `transcripts`/`screen_content`/`speakers` value the identical way `_assess_chunk_coverage` did, one function away; both now go through `_usable_task_list` with a throwaway `note_sink=[]` (the sibling `merge_transcript_json` call already owns the warning for the same value) - test contract `tests/test_transcript_quality_guard.py::TestDialogueEntriesFromRawJsonRejectsNonListScalars` (pure function, including the bare-string case that stays safe by luck) and `TestSingleShotCallerSurvivesAScalarTranscriptsValue` (real caller, `process_transcript` with only the Gemini call stubbed).
- **Both `process --url` AND `process --file` chunk the transcript on long videos.** Issue #50 added URL chunking via Gemini's `VideoMetadata.start_offset/end_offset`. The 2026-05-02 inversion extended the same mechanism to local files: each chunk is a separate Gemini call against the **same `file_uri`**, so the "one upload" guarantee is preserved (no per-chunk re-upload). Empirical proof: implicit-cache hit at `cached=560495` on a follow-up call against the same upload — Gemini deduplicates the input prefix across calls. Chunking is auto-triggered when duration exceeds `--chunk-minutes` (default 50) on either path. Manual `--start`/`--end` disables auto-chunking on the file path because the user has explicitly chosen a segment. Reviewers: any change that disables chunking on `--file` for long videos re-introduces the empirical malformed-JSON failure mode observed on hour-long single-shot transcript requests (different break point each run, irrecoverable).
- **Both `process --url` AND `process --file` invert ordering: transcript first, mindmap-from-transcript second.** Issue #54 inverted `--url` and the scan loop; the 2026-05-02 patch extended the same inversion to `cmd_process --file`. Both paths now follow: Step 1 transcript (chunked if long) → Step 2 mindmap (resolver picks `source="transcript"` when the on-disk transcript exists, else `source="video"` fallback) → Step 3 concepts. The transcript step is wrapped in try/except so an uncaught exception still lets the mindmap-from-video fallback run (preserves the "mindmap is the AI's discovery surface and must always run" invariant). Reviewers: any diff that reorders `cmd_process --file` to mindmap-first, or that bypasses `resolve_mindmap_source` to hardcode `source="video"` on the local-file path, needs explicit cost-and-reliability justification — mindmap-from-transcript is ~10× cheaper than mindmap-from-video on hour-long inputs (text-only call against a ~50KB transcript vs video frames at 70 tok/frame), and inverted ordering catches transcript bugs at Step 1 instead of after paying for mindmap. Test contract: `tests/test_mindmap_from_transcript.py` (`TestCmdProcessUrlInversion`, `TestCmdScanInversion`) for the URL/scan paths.
- **`resolve_mindmap_source` four-value contract is load-bearing.** The resolver returns `"video" | "transcript" | "skip"` from input `mindmap_source: auto|video|transcript|none`. `auto` (default) silently falls back to video when no transcript is on disk — this is what makes the inversion safe to ship as the new default. `transcript` MUST raise `ValueError` on missing transcript; do NOT silently fall back, because that masks the user's explicit-knob conflict (most often `mindmap_source: transcript` paired with `skip_modes['transcript']`). `none` skips the mindmap step entirely. The resolver only consults file presence, not `skip_modes` — the upstream transcript loop honors that knob, and a stale on-disk transcript flips `transcript_available=True` (which is the right behavior — use what we have). Healthy `transcript_status` values are both `"ok"` (chunked + scan single-shot writers) and `"complete"` (single-call success writer); only `"partial"` (salvage) triggers the partial-source mindmap header. The `_HEALTHY_TRANSCRIPT_STATUSES` set MUST stay in sync with the writers. Reviewers: grep for `resolve_mindmap_source` and `_HEALTHY_TRANSCRIPT_STATUSES` in any retrieval/scan diff and confirm the four-value + healthy-set contracts are intact.

  **Issue #157 containment amendment:** `resolve_mindmap_source` also accepts a keyword-only `transcript_severe: bool = False`. Only the `"auto"` branch reads it - a severe-flagged transcript (per `transcript_quality_flags_are_severe`) is treated as UNAVAILABLE, so `auto` falls back to `"video"` exactly as it would for a missing transcript, rather than feeding a known-corrupt transcript into mindmap generation and letting the corruption propagate into concepts, `taxonomy.json`, and the LanceDB index. An explicit `mindmap_source: transcript` is honored regardless of severity - the operator asked for it by name, and the existing partial-source provenance header (`process_mindmap`'s own `transcript_status not in _HEALTHY_TRANSCRIPT_STATUSES` check, unchanged) already marks the artifact, since a severe transcript's `transcript_status` is `"partial"`. The default `False` is deliberate: any call site not yet updated to compute and pass severity keeps its EXACT pre-#157 behavior. All four call sites (`cmd_scan`'s mindmap loop, `cmd_mindmap`, `_cmd_process_url`, `cmd_process --file`) read severity from the SAME on-disk `transcript_quality_flags` the transcript writer just persisted (`_transcript_quality_severe_from_meta`), never a separately re-derived judgment. Test contract: `tests/test_transcript_quality_guard.py::TestResolverContainment`.
- **Config-knob resolvers (`resolve_transcript_source`, `resolve_mindmap_source`) are guarded at EVERY call site - single-video, scan-per-channel, AND the scan's per-video threaded closure - and the guard must catch `(ValueError, TypeError)` together (issue #135).** Both resolvers reject an invalid string with `ValueError`, but a non-string YAML value (a mapping like `transcript_source: {mode: auto}`, or a sequence like `[auto]`) fails the `raw not in {...}` membership test with `TypeError` instead - a bare `except ValueError` lets that shape escape and kill the whole scan, which is the exact defect #135 exists to fix. `resolve_chunk_minutes` maps this at the SOURCE (`except (TypeError, ValueError): raise ValueError(...) from None`, ~:1861); the two resolvers here do not, so the guard has to be at every call site. **Eight sites are guarded this way** - `resolve_transcript_source` at `cmd_scan`'s per-channel loop (~:5552), `cmd_transcript`'s CLI-only placeholder (~:6180) and its `--url` channel-config branch (~:6354), and `_cmd_process_url` (~:6751); `resolve_mindmap_source` at `cmd_scan`'s per-video mindmap closure (~:5709), `cmd_mindmap` (~:6074), `_cmd_process_url` (~:6895), and `cmd_process --file` (~:7374). The first pass of this guard (PR #167) missed the per-video mindmap closure - it is the highest-severity of the eight, not a minor gap: it runs inside a `ThreadPoolExecutor` worker (`executor.submit(_build_mindmap_call, v)`), and `future.result()` re-raises whatever the worker raised with NOTHING above it to catch - so an escaping `TypeError` there does not just fail one video the way the closure's normal `return v_prefix, f"error: {exc}"` does, it takes down the whole mindmap stage for the channel after transcript work and quota are already spent. The single-video sites `log.error` + `sys.exit(1)` (one video, nothing to continue to). `cmd_scan`'s per-channel site instead `log.error`s (naming the channel, and stating explicitly that the ENTIRE channel is skipped - not just transcripts) and `continue`s to the next channel, AND appends `(ch_name, ch_name, f"error: {e}")` to the scan's `errors` list so the channel shows up in the end-of-scan `--- N FAILED ---` summary - a `continue` with no `errors.append` leaves the scan reporting `Done.` and exiting 0 with a channel silently dropped, which two independent review passes caught on the first cut of this guard. The whole channel is skipped deliberately, not just the transcript step: disabling only transcript would flip `mindmap_source: auto` onto the ~10x more expensive mindmap-from-video path, so a one-character config typo would start spending real Gemini money instead of just failing loudly - skipping the whole channel is the cheap, operator-fixable outcome. The per-video mindmap closure keeps its EXISTING `return v_prefix, f"error: {exc}"` shape unchanged - it already lands in the failure summary through the normal task-result path (`if status.startswith("error"): errors.append(...)`), so do NOT convert it to a direct `errors.append` call; the two mechanisms (channel-level early continue vs. per-video task result) are both correct in their own contexts and must not be collapsed into one. `resolve_chunk_minutes` is now guarded at all four of its call sites too (issue #168), matching the eight sibling sites' `except (ValueError, TypeError)` shape - one consistent config-typo UX across every single-video command. Before #168, three of its four call sites (`_cmd_transcript_impl`, `_cmd_process_url`, `_cmd_process_impl` --file) had NO exception handling AT ALL, so a plain string `chunk_minutes` typo already raw-tracebacked out of all three; only the `cmd_scan` per-channel site was guarded, and only narrowly (`except ValueError`, no `errors.append`). Two fixes land on the scan site specifically: the catch widens to `(ValueError, TypeError)` for consistency (belt-and-braces - `resolve_chunk_minutes` already source-maps `TypeError` to `ValueError` internally, so this closes no live hole there, but a reviewer should not have to know that to trust the catch matches its siblings), and it now appends `(ch_name, ch_name, f"error: {e}")` to the scan's `errors` list before the `continue` - the same fix issue #135 made to the sibling `resolve_transcript_source` guard a few lines up, applied here because this site had been missed: a `continue` with no `errors.append` leaves the scan reporting `Done.` and exiting 0 with a channel silently dropped. The three single-video sites get the established log-and-exit(1) shape (one video, nothing to continue to). **On `process --file` the guard's PLACEMENT is the point of the fix on that path, and it is pinned between two things.** Traced from the real call order rather than the diff: the upload sits ABOVE where the guard naturally wanted to go, so simply wrapping the existing call would have improved the error message while still charging the operator a full multi-minute MP4 upload for a one-character typo. Verified on the real CLI - pre-fix, `process --file` with `chunk_minutes: thirty` logged `Uploading video: dummy.mp4` and created a Gemini file server-side before failing; post-fix it exits 1 with no network call at all. This is the repo's standing **probe before you pay** rule (the `probe_atomic_writes` guardrail below is the same shape). `resolve_chunk_minutes` is a pure function of the config, so nothing in the hoisted block depends on the upload having happened. Reviewers: a diff that moves this guard back down next to its sibling reads as a tidy-up and silently restores the cost. The test that catches it asserts the ORDERING (`upload_local_video` never called), not the exit code - an exit-code-only assertion passes either way, and was confirmed to pass against the un-hoisted code while the ordering test failed. Test contract: `tests/test_chunk_minutes_guards.py::TestProcessFileChunkMinutesGuard::test_guard_runs_before_the_gemini_upload_not_after`. The LOWER pin matters just as much and a first cut of this fix broke it (caught by the Codex peer pass): the guard must sit BELOW the legacy `skip: true` early return, because a video the operator deliberately suppressed has to stay a no-op exit 0 rather than failing on a knob governing work that was never going to happen - a caller looping over a mixed list of videos would otherwise break on the first suppressed entry. So the guard lives strictly between the skip return and the upload, and a diff that moves it to either side breaks one of the two. Test contract for the lower pin: `::test_a_deliberately_skipped_video_stays_a_no_op_exit_0`. Both tests were falsified by moving the guard the wrong way and confirming exactly the one relevant test failed. Separately, `resolve_chunk_minutes` itself gained a boolean rejection: PyYAML types an unquoted `yes`/`true` as Python `True`, `bool` subclasses `int`, and `int(True) == 1` - so `chunk_minutes: yes` used to silently resolve to a 1-minute chunk size (roughly 60 Gemini calls on an hour-long video instead of 2), raising nothing and logging nothing. The `isinstance(candidate, bool)` check MUST run before the `int()` coercion - `isinstance(True, int)` is also `True`, so checking order the other way round never fires. Reviewers: grep for `isinstance(candidate, bool)` in any diff touching `resolve_chunk_minutes` and confirm it still precedes the `int()` call. Test contract: `tests/test_chunk_minutes_guards.py` (resolver-level boolean/precedence/leniency cases, the `--dry-run` preflight surfacing the boolean rejection, and caller-level cases for all four sites including the issue's sharpest illustration - a valid `transcript_source` paired with a typo'd `chunk_minutes` on the same `channel_cfg` in `_cmd_process_url`).
- **`validate_channel_knobs` makes `scan --dry-run` a real preflight, and it is REPORT-ONLY - moving a skip into it is a behavior change, not a fix (issue #169).** `--dry-run` used to return before the per-video knob resolvers ever ran, so a typo'd `transcript_source`/`chunk_minutes`/`mindmap_source` rendered as a healthy channel and the operator only learned on the real (paid) run - the same gap PR #167's implementer hit directly while proving #135's guard (had to drive `cmd_scan` with a harness because dry-run structurally could not demonstrate it). Thirteen things reviewers must protect:
  1. **Reuse the resolvers, never re-derive them.** `validate_channel_knobs` calls the REAL `resolve_transcript_source`, `resolve_chunk_minutes`, and `resolve_mindmap_source` - the standing "a verifier must use the WRITER's path, never re-derive its own" guardrail (the PR #136 entry above) applies here just as much as to a path check: a hand-rolled copy of the valid-value sets would drift the moment a resolver changes, and the preflight would start disagreeing with the runtime it exists to predict.
  2. **`resolve_mindmap_source(channel_config, transcript_available=True)` - `True` is deliberate, and flipping it to `False` reintroduces a false alarm on every `mindmap_source: transcript` channel.** `True` isolates the ENUM check (is the string one of the four valid values?) from the availability conflict (transcript not on disk yet). `transcript_available=False` would make the resolver raise its "no transcript is available" `ValueError` for every channel legitimately configured `mindmap_source: transcript` whose transcripts simply have not been written on THIS run - a permanent false alarm on a healthy config. A guard whose only value is being believed must never cry wolf. Test contract: `tests/test_channel_knob_preflight.py::TestMindmapSourceTranscriptAvailabilityIsolation::test_mindmap_source_transcript_is_not_a_false_alarm_before_transcripts_exist`.
  3. **REPORT ONLY - no `continue`, no `sys.exit`, no `errors.append` at the preflight call site inside `cmd_scan`'s channel loop.** The existing runtime skip/failure sites (`resolve_transcript_source` and `resolve_chunk_minutes` inside the `auto_transcript == "all"` block, `resolve_mindmap_source` in the per-video mindmap closure, the numeric knobs' own use sites) are UNCHANGED and still own where the actual skip or per-video failure happens. A diff that has the preflight `continue` the channel, or that deletes one of the existing sites on the theory the preflight now covers it, collapses call shapes that are both correct in their own contexts (a whole-channel skip vs. a per-video `error:` status vs. an aborted run) and changes runtime routing - exactly what issue #169 explicitly rejects ("reporting problems without moving where the skip actually happens... keeps runtime behavior identical"). A real (non-dry-run) scan legitimately logs the same problem TWICE - once from the preflight, once from the skip site - by design: the first says the config is invalid, the second says what is being done about it.
  4. **Placement is load-bearing, not incidental.** The call sits immediately after `ch_name`/`ch_url` are read and BEFORE `get_channel_id(...)` (so the diagnostic surfaces before this channel's first YouTube-quota call, not buried after it) and BEFORE the `if args.dry_run: ... continue` early return (this ordering IS the fix - without it, `--dry-run` structurally cannot reach the resolvers). Moving the call below either point reopens the exact gap #169 closes.
  5. **Consequence strings are derived from the channel's own settings, not hardcoded per knob**, and reuse the module-level constants (`KNOB_CONSEQUENCE_ABORTS_SCAN`, `KNOB_CONSEQUENCE_SKIPS_CHANNEL`, `KNOB_CONSEQUENCE_FAILS_TRANSCRIPTS`, `KNOB_CONSEQUENCE_FAILS_MINDMAPS`, `KNOB_CONSEQUENCE_NOT_REACHED`, `_MANUAL_COMMAND_SUFFIX`) so tests assert on them without duplicating literals. `transcript_source`/`chunk_minutes` report `KNOB_CONSEQUENCE_SKIPS_CHANNEL` only when `auto_transcript == "all"` (the only case where those resolvers are ever reached at runtime); `mindmap_source` reports `KNOB_CONSEQUENCE_FAILS_MINDMAPS` only when `auto_mindmap != "none"`. Everywhere a knob's own consequence would be `KNOB_CONSEQUENCE_NOT_REACHED`, the finding logs at WARNING (not ERROR) - claiming a healthy-but-typo'd channel will be skipped or abort the scan, when it will not be reached by the running scan at all, is its own false alarm. Test contract: `tests/test_channel_knob_preflight.py` (the full unit + caller-level suite) and the updated `tests/test_manual_url_transcript_source.py::TestScanChannelConfigTypoSkipsOnlyThatChannel::test_typo_channel_skipped_healthy_channel_still_processed`, which now expects the doubled ERROR line.
  6. **The `--dry-run` NOTE is the other half of the fix, and it is dry-run only.** Reporting the typo without it leaves the preview contradicting itself: the preflight says the channel would be skipped or the scan aborted, and the very next lines announce `Found N videos, N new` and list them, so the operator reads a count of work a real run would not do. `knob_blocks_channel` (true only for `KNOB_CONSEQUENCE_SKIPS_CHANNEL`) drives one ERROR line inside the `if args.dry_run:` branch naming the count that would NOT be processed. `knob_aborts_scan` (true only for `KNOB_CONSEQUENCE_ABORTS_SCAN`, checked FIRST) drives a second, stronger NOTE form. Both flags are read off the ALREADY-DOWNGRADED `knob_problems` list (see item 10), so at most one of them can ever be true for a given channel - the `if knob_aborts_scan: ... elif knob_blocks_channel:` branch is mutually exclusive by construction, not merely by check order. Both flags are read NOWHERE else, so a real run's routing is untouched. Neither fires for a `mindmap_source`-only or a `transcript_timeout_seconds`-only problem: neither stops the channel, so claiming either NOTE would be its own false alarm. This half was found by EXECUTING Gate 1 on the real CLI against the real `config.yaml`, not by reading the diff - the first cut logged the error and still previewed 58 videos as if they would be processed. Test contract: `tests/test_channel_knob_preflight.py::TestScanDryRunPreflight::test_dry_run_note_says_the_listed_videos_would_not_be_processed`, `::test_dry_run_note_is_absent_for_a_non_blocking_knob_problem`, and `::test_skips_note_wins_when_the_downgraded_max_duration_problem_would_otherwise_falsely_claim_aborts` (the direct P1-B(ii) regression test: without the downgrade, a channel with both a `SKIPS_CHANNEL` and a later `ABORTS_SCAN` problem used to render the FALSE "aborts" NOTE).
  7. **`transcript_max_duration_seconds` is `KNOB_CONSEQUENCE_ABORTS_SCAN`; `transcript_timeout_seconds` is `KNOB_CONSEQUENCE_FAILS_TRANSCRIPTS` - and the two knobs look IDENTICAL in the source.** Neither has a resolver; `cmd_scan` reads both straight off the channel dict and uses them numerically after the dry-run early return, so a bad value raises an uncaught `TypeError` either way. But `transcript_max_duration_seconds`'s `TypeError` has no handler anywhere in the channel body and genuinely kills the run mid-scan, taking every later channel with it - the same failure shape as a bad `prompt` (`load_prompt` runs unconditionally before any per-mode gate). `transcript_timeout_seconds`'s `TypeError`, by contrast, surfaces INSIDE `_run_with_timeout` and lands in the existing per-video `except Exception` handlers on BOTH the single-shot and chunked transcript paths - so the scan completes normally and every later channel still runs; only that channel's transcripts fail. **This was established by EXECUTING the runtime, not by reading it** - a reviewer must not "unify" the two consequences on the theory that they read alike in the source; that unification is exactly the P1-A defect round 3 fixed (round 2 gave both knobs `ABORTS_SCAN` and the test suite that shipped it never touched the runtime, because it was parametrized over both knob names and asserted a module constant against itself). Test contract: `tests/test_channel_knob_preflight.py::TestTranscriptTimeoutVsMaxDurationConsequence` (the two knobs get separate, explicitly-named tests, not one parametrized test) and `::TestValidateChannelKnobsPrompt`.
  8. **`resolve_prompt_path` exists ONLY because `load_prompt` cannot answer "would this resolve?" without exiting.** It was split out of `load_prompt` (which still calls it, then checks `.exists()` and `sys.exit(1)`s) so the preflight can ask the same question the loader answers, without the answer being a process exit. A preflight that instead re-derives `SKILL_DIR / "prompts" / f"{name}.md"` (or any other independent path-building) is the PR #136 checker/writer-path-drift class verbatim: a preflight looking in a different place than the loader it is predicting is worse than no preflight, because it can pass on a config that will actually fail, or fail on one that will actually resolve. Reviewers must push back on any diff that rebuilds the prompts path inside `validate_channel_knobs` instead of calling `resolve_prompt_path`. Test contract (load-bearing, not incidental): `tests/test_channel_knob_preflight.py::TestResolvePromptPathMatchesLoadPrompt` derives both halves independently and compares them - `resolve_prompt_path(name).read_text() == load_prompt(name)` for a resolving name, and `pytest.raises(SystemExit)` from `load_prompt` for a name `resolve_prompt_path` reports as missing.
  9. **The constant is `KNOB_CONSEQUENCE_NOT_REACHED`, never "inert" - and the manual-command clause is PER-KNOB, not a blanket claim appended to the constant itself.** Round 2 called the same consequence "inert" and paid for it: an adversarial reviewer executing (not reading) `transcript --url --channel X` against a channel with exactly that typo got `SystemExit 1`, because the manual `transcript`/`mindmap`/`process --channel` commands resolve the SAME channel dict as scan (issue #127) and hit the same resolver. Round 2's fix over-corrected the other direction - it baked a blanket "still fails the manual transcript/mindmap/process --channel commands" clause into `KNOB_CONSEQUENCE_NOT_REACHED_BY_SCAN` itself, and round 3's AST walk of every command body proved that clause FALSE for two knobs: `transcript_max_duration_seconds` and `transcript_timeout_seconds` are read by `cmd_scan` ALONE, so telling an operator their typo also breaks `transcript --url` sends them hunting a failure that cannot happen - a diagnostic promising a failure that cannot happen is its own false alarm, symmetrical to the "inert" defect it replaced. The fix: `KNOB_CONSEQUENCE_NOT_REACHED` now carries no blanket claim at all, and `_MANUAL_COMMAND_SUFFIX` is appended only for `_MANUAL_COMMAND_KNOBS = {transcript_source, mindmap_source, chunk_minutes}` - the three knobs `cmd_transcript`/`cmd_mindmap`/`_cmd_process_url` genuinely also resolve off the same channel dict. A diff that widens or narrows `_MANUAL_COMMAND_KNOBS` needs the same AST-grade justification (walk every command body, don't assume). Test contract: `_not_reached_for()` in `tests/test_channel_knob_preflight.py` builds the expected string from the constants for every case, never hardcoding prose.
  10. **The runtime-firing-order model and `_downgrade_unreached_knobs`: the check ORDER IS THE CONTRACT, not an implementation detail.** `validate_channel_knobs` appends problems in the exact order `cmd_scan`'s channel body reaches the corresponding checks at runtime - `prompt`, `transcript_source`, `chunk_minutes`, `transcript_max_duration_seconds`, `transcript_timeout_seconds`, `mindmap_source` - each carrying the consequence it has when it is the FIRST to fire. `_downgrade_unreached_knobs` then walks that list once and rewrites every problem AFTER the first STOPPING one (`KNOB_CONSEQUENCE_ABORTS_SCAN` or `KNOB_CONSEQUENCE_SKIPS_CHANNEL`, the two members of `_KNOB_STOPPING_CONSEQUENCES`) to `KNOB_CONSEQUENCE_NOT_REACHED` (plus the item-9 suffix where the knob is a `_MANUAL_COMMAND_KNOBS` member), because once a stopping knob has fired nothing checked later in the real channel body ever executes - reporting its own consequence would describe code that never runs. **A knob appended out of order breaks this silently**: a stopping knob added AFTER a knob it should have suppressed produces a composite report that contradicts itself (claims both a real consequence for the later knob AND, via the NOTE selection in item 6, that the whole channel is skipped or the scan aborts) while every test that checks individual knobs in isolation stays green - only a test that asserts the ORDER itself catches it. Test contract: `tests/test_channel_knob_preflight.py::TestValidateChannelKnobsCheckOrderIsTheContract::test_every_knob_bad_at_once_reports_in_documented_runtime_order_with_exactly_one_stopping_consequence` (every knob bad at once; asserts both the exact order and that exactly one stopping consequence survives) and `TestDowngradeUnreachedKnobsDirectly` (the downgrade rule proved on synthetic `(knob_name, message, consequence)` tuples, independent of any config: empty list, no stopping consequence, stopping first, stopping last, and the manual-suffix application).
  11. **The downgrade keys on the STOPPING consequences ONLY - `KNOB_CONSEQUENCE_FAILS_TRANSCRIPTS` and `KNOB_CONSEQUENCE_FAILS_MINDMAPS` must never mask each other.** Neither consequence stops the channel, so a naive "downgrade everything after the first problem" implementation - keying on "any problem seen so far" instead of `_KNOB_STOPPING_CONSEQUENCES` membership - would wrongly demote a later `FAILS_MINDMAPS` to `NOT_REACHED` just because an earlier `transcript_timeout_seconds` problem reported `FAILS_TRANSCRIPTS`. Both must survive as reported when neither is a stopping consequence. Test contract: `tests/test_channel_knob_preflight.py::TestValidateChannelKnobsCompositeDowngradeEdgeCases::test_fails_transcripts_and_fails_mindmaps_together_neither_downgrades_the_other`. The original composite rule this generalizes - a `mindmap_source` problem downgrading when a `SKIPS_CHANNEL` problem already fired for the same channel - is still covered: `tests/test_channel_knob_preflight.py::TestValidateChannelKnobsConsequenceStrings::test_skips_channel_problem_downgrades_a_coexisting_mindmap_problem_to_not_reached` and `::test_bad_mindmap_source_alone_still_fails_every_mindmap_composite_fix_did_not_flatten_the_normal_case` (a lone bad `mindmap_source` with no other problem at all must still report `FAILS_MINDMAPS`, proving the downgrade did not flatten the normal case).
  12. **`min_duration_seconds` is deliberately NOT validated here.** It is read and used numerically BEFORE the `--dry-run` early return (inside the per-channel `if min_duration:` block, ahead of the `if args.dry_run:` check), so a bad value already crashes visibly during a dry run with today's code - which is exactly the outcome this whole preflight exists to produce. Adding a redundant check for it here would not fix a gap; it would just be dead code shadowing a crash that already surfaces at the right time.
  13. Test contract for the full surface: `tests/test_channel_knob_preflight.py::TestValidateChannelKnobsPrompt`, `::TestTranscriptTimeoutVsMaxDurationConsequence`, `::TestReviewerVerifiedRuntimeBehavior` (the six cases an adversarial reviewer verified by executing the runtime, locked in line for line), `::TestValidateChannelKnobsCompositeDowngradeEdgeCases`, `::TestValidateChannelKnobsCheckOrderIsTheContract`, `::TestDowngradeUnreachedKnobsDirectly`, `::TestResolvePromptPathMatchesLoadPrompt`, the composite-rule tests under `::TestValidateChannelKnobsConsequenceStrings` named above, and the caller-level `::TestScanDryRunPreflight::test_dry_run_note_says_aborts_and_no_later_channel_for_a_typoed_prompt`, `::test_aborts_note_takes_precedence_over_skips_note_when_a_channel_has_both_problems`, `::test_skips_note_wins_when_the_downgraded_max_duration_problem_would_otherwise_falsely_claim_aborts`, `::test_typoed_transcript_source_without_auto_transcript_logs_warning_with_manual_commands_wording`.
- **Mindmap is the AI's discovery surface and must always run on `process --url`.** The Step 1 transcript call in `_cmd_process_url` is wrapped in try/except so an uncaught exception still lets Step 2 mindmap run with `source="video"` fallback. Reviewers: any diff that drops or narrows that try/except (or removes the test `test_mindmap_still_runs_when_transcript_step_raises`) regresses the user's "mindmap always runs" invariant.
- **Mindmap-from-video and single-shot transcript default to `MEDIA_RESOLUTION_LOW`.** Both `process_mindmap` (with `source="video"`) and `process_transcript` MUST pass `media_resolution=MEDIA_RESOLUTION_LOW` to `call_gemini` by default. This mirrors the chunked-transcript path's pattern at `scripts/video_intel.py:1466`. Issue #58 Gate 3 established that LOW yields equivalent quality at 3× lower input-token cost for our prompts' needs (theme extraction + diarization on talking-head + slide content); HIGH would re-introduce Gemini's 1M-token ceiling on hour-long videos (~67-min limit at HIGH = ~258 tokens/frame at 1 FPS). Empirical: a 91-min 1080p video at LOW = ~563K input tokens (verified), comfortably under cap. The CLI escape hatch is `--media-resolution {low,high}` on `process`, `mindmap`, and `transcript` subparsers. Reviewers: any new `process_mindmap(source="video")` or `process_transcript` call site that omits `media_resolution` or hardcodes HIGH unconditionally needs pushback. The user-facing flag default at `process_parser` / `mm_parser` / `tx_parser` MUST stay `default="low"`. Test contract: `tests/test_mindmap_media_resolution.py` (`TestProcessMindmapFromVideoMediaResolution`, `TestProcessTranscriptMediaResolution`, `TestResolveMediaResolutionHelper`, `TestCmdMindmapMediaResolutionThreading`).
