# Decisions One worklist, every project, every kind: `/browse/decisions`, or `umtool decisions`. ## Severity is EARNED | | | |---|---| | `blocking` | something downstream would **lie or die** if you acted on it | | `open` | a real decision nobody has made | | `info` | a true fact that is not a decision | An inbox that marks four missing cuts per song as blocking is an inbox nobody opens twice. A song that never had a vertical is not waiting on you. ## It is a REDUCER, and it must never measure Every call it makes is one a project page already makes. An inbox that shells out to ffmpeg once per rendition, or to yt-dlp once per source, is an inbox that takes a minute to open — which is the one thing it cannot afford to be. So loudness is read from the **cache**, and availability is read from whatever the preflight last **wrote** (`out/availability.json`), never measured here. "Nobody has ever run one" is itself something it can say. ## The kinds Each registry entry owns its own vocabulary (`decisionKinds`), and the union is assembled rather than hand-written — a closed union in one shared file would mean every future kind editing it to say a word only it uses. **report-video** | kind | severity | trigger | |---|---|---| | `manifest-invalid` | blocking | missing or `localhost` `siteOrigin`, missing `channelSlug`, duplicate ids, `section` out of range | | `clip-no-cues` | blocking | no `transcript.cues.json` — the build dies there | | `clip-unfetchable` | blocking | the last preflight says the source is gone | | `clip-mid-sentence` | open | the cut lands inside a cue that does not close a sentence, and `lockEnd` is unset | | `window-overlap` | open/info | two clips from one source overlap | | `no-punctuation` | info | a source's ASR has no terminators — one row per project | | `clip-cue-gap` | open | the source's cue file **ends before a clip does** (`cues end 880 s · c03 needs 897 s`); one row per source, naming the clip that reaches furthest. The build does not die — it cuts from audio — but the tail has no words behind it; recover the transcript or shorten the window | | `claim-unadjudicated` | blocking | a `ledger[]` entry missing any of the six adjudication fields | | `claim-incoherent` | open | a fired coherence predicate, in plain words | | `stale-build` | open | the output is older than the manifest | | `unbuilt` | info | never built. A normal state, not a decision | `claim-unadjudicated` **earns** blocking: both the stated and the implied total lie if you act on an unadjudicated ledger, and they lie quietly, in a chart, with somebody's name on it. It is also the one kind that is deliberately **one row per entry** rather than collapsed — fifty unpunctuated sources are the same true thing said fifty times, but fifty unadjudicated claims are fifty *different* decisions, and the inbox being empty is the sign-off. `claim-incoherent` is `open` rather than blocking because the decision is **editorial**: is the flag right, and does it belong on screen? Neither answer stops a build. Both come from `ledger-totals.mjs` in `report-to-video`, which is pure arithmetic over JSON already parsed into memory — so the reducer stays a reducer. Audio is fetched only when a claim page is opened, one claim at a time. **song** — the nine the existing reducer emits: `unjudged-variant`, `missing-cut`, `no-recipe`, `stale-recipe`, `spec-problem`, `no-plan`, `unattributed`, `thumb-unaccepted`, `loudness`. **routing**, kind-independent — `shadowed-name` (blocking), `unroutable-name` (info), `ambiguous-project` (blocking). ## Adding one Extend the kind's `decisionKinds` and emit it from its `decisions(ctx, summary)`. Nothing outside `lib/projects/` needs to change. ## What the CLI can and cannot do `umtool check` computes every report-video decision and every routing one, and exits 1 on anything blocking — which is what makes it usable as a gate before a build. It **cannot** compute the song reducer: that is TypeScript beside `readSong()`, the loudness cache and the accepted cover set, and a second implementation is the thing this design exists to avoid having two of. It says how many projects it only checked the routing of. ## Discovered by getting it wrong once **Forty true rows are worse than one.** The first real run emitted a `no-punctuation` row per *source* — forty-odd across six projects, all correct, burying two blocking rows off the top of the list. One per project now. **A reducer that says the opposite of the card next to it.** The first adjudicated build shipped a rail tally reading "Spans everything **18**" directly beside a card reading "He never said eighteen". The adjudication had set `valueKind: "derived"` and nothing downstream was reading it: the tally still took the latest value of any kind, and the ledger's `label` still said "the only explicit sum in the corpus". A field that changes what something *means* has to be chased through every renderer that prints it, and the only way this surfaced was looking at a frame. **Do not assume where a project reads its cues from.** The first run confidently reported three sources of `quartering-flagging-takedowns` as having no cue file. They cite deleted YouTube uploads, are cut from live Rumble mirrors, and build against a **shadow `CHANNELS_DIR`** the project's own `make-shadow-channels.sh` writes. A project now says which directory it reads — `provenance.channelsDir`, or the `.shadow-channels` convention that already existed — and neither is a guess: both are things the project wrote down. When the builder is present but unrun, the decision says to run it rather than declaring the sources gone.