v4.0.0 — deterministic attribution: the summary is composed from checked events, not asserted #434

Merged
claude-bot merged 54 commits from feat/v4-deterministic-attribution into main 2026-08-28 23:48:13 +00:00
Contributor

Closes the v4.0.0 milestone — 30 of 30 issues.

The summary is no longer something the model asserts. It is composed from events that have each been checked against the transcript in code, so chronology is a sorted() and attribution is a lookup rather than a model behaviour.

The load-bearing change

#342 removed the unverifiable attribution join. Transcription used to send every speaker's track in one /transcribe/session request and recover attribution by mapping each returned label back to the track it was sent for. That join cannot be made safe: a one-directional swap is caught, because the victim track comes back empty, but a symmetric swap is not — every label sent comes back, every segment resolves, every coverage check passes, and the whole session is misattributed with nothing raised and nothing logged.

Joining on an id instead of a name would not have closed it either; a permutation of ids survives an id-join exactly as a permutation of names survives a name-join. Any echo-based join is unverifiable in principle, because the only thing that could confirm it is the association being asserted. So there is now one request per track and the label is stamped from the file that was sent. There is nothing left to permute.

If you run with VAD disabled, request volume changes — one request per speaker instead of one per session, to the same endpoint the VAD path already used. VAD is on by default, so most installs are unaffected.

Migrations

Six, applied in order by the migrate service: a2b3c4d5e6f8, b3c4d5e6f7a9, c4d5e6f7a8b0, d5e6f7a8b0c1, e6f7a8b0c1d2, f7a8b0c1d2e3. All additive. Only c4d5e6f7a8b0 backfills — it copies each member's existing character into campaign_characters and leaves the old columns in place, so a downgrade is two DROP TABLEs and no data is at risk.

Rehearsed against a full copy of production data before release: all six apply cleanly, row counts unchanged, backfill exact.

Versions

BOT_CONTRACT_VERSION stays 1, so images can be upgraded one at a time. BOT_EXPECTED_APP_VERSION moves to 4.0.0, which only warns on mismatch.

Late addition — #431

check_transcript_covers_session measured the transcript's last timestamp against the recording's wall clock. Every track is tail-padded with silence to that clock and nothing stops a recording when the channel empties, so a table that played for two hours and forgot to stop for another fifty had a complete, correctly attributed transcript rejected for covering "only" 58%.

This release made it worse rather than introducing it: the guard predates v4.0.0, but admin retry used to pass duration=0, which skipped it — so a long-tailed session that failed could at least be recovered. #421's derive_session_duration arms the guard on exactly that recovery path. Coverage is now measured against how far into the session the tracks carry audible speech.

Verification

  • 1,369 backend tests, 211 bot tests, ruff clean
  • Migrations rehearsed against production data
  • Live eval run recorded against the real session fixture; measured accuracy-neutral versus the pre-milestone baseline (n=4 each). Extraction is 0.818 with zero variance on both sides, so the observed movement is compose sampling, which is by design (#232, #423)
  • A full acceptance-criteria pass over all 30 issues, verified by mutation rather than by reading — it found a dead validator check whose test could not fail, a dedupe that preferred hallucinations over verified beats, an unbounded recursion that killed the test runner, and the default transcription path having no regression coverage. All fixed in this branch.

Known, filed, not blocking

Two defects found while designing the synthetic-audio test harness (#433), both verified in code, both in the v4.0.1 milestone:

  • #432#425's uncaptured-member fix is overridden. speakers is never narrowed after drop_silent_tracks, so a backend-dropped silent speaker is still proposed as having spoken. Only reachable on the backend-drop route (an older recording, a hand-placed directory, a bot a version behind) — which is precisely the route #425 exists for.
  • #429 — backups are written by pg_dump 17 against a PostgreSQL 16 server and cannot be restored by the host's own tooling. Relevant to anyone taking the CHANGELOG's advice to snapshot before applying these six migrations: take a filesystem or VM snapshot, not just the automatic dump.

🤖 Generated with Claude Code

Closes the v4.0.0 milestone — 30 of 30 issues. The summary is no longer something the model asserts. It is composed from events that have each been checked against the transcript in code, so chronology is a `sorted()` and attribution is a lookup rather than a model behaviour. ## The load-bearing change **#342 removed the unverifiable attribution join.** Transcription used to send every speaker's track in one `/transcribe/session` request and recover attribution by mapping each returned label back to the track it was sent for. That join cannot be made safe: a *one-directional* swap is caught, because the victim track comes back empty, but a **symmetric** swap is not — every label sent comes back, every segment resolves, every coverage check passes, and the whole session is misattributed with nothing raised and nothing logged. Joining on an id instead of a name would not have closed it either; a permutation of ids survives an id-join exactly as a permutation of names survives a name-join. Any echo-based join is unverifiable in principle, because the only thing that could confirm it is the association being asserted. So there is now one request per track and the label is stamped from the file that was sent. There is nothing left to permute. **If you run with VAD disabled, request volume changes** — one request per speaker instead of one per session, to the same endpoint the VAD path already used. VAD is on by default, so most installs are unaffected. ## Migrations Six, applied in order by the `migrate` service: `a2b3c4d5e6f8`, `b3c4d5e6f7a9`, `c4d5e6f7a8b0`, `d5e6f7a8b0c1`, `e6f7a8b0c1d2`, `f7a8b0c1d2e3`. All additive. Only `c4d5e6f7a8b0` backfills — it copies each member's existing character into `campaign_characters` and leaves the old columns in place, so a downgrade is two `DROP TABLE`s and no data is at risk. Rehearsed against a full copy of production data before release: all six apply cleanly, row counts unchanged, backfill exact. ## Versions `BOT_CONTRACT_VERSION` stays **1**, so images can be upgraded one at a time. `BOT_EXPECTED_APP_VERSION` moves to 4.0.0, which only warns on mismatch. ## Late addition — #431 `check_transcript_covers_session` measured the transcript's last timestamp against the recording's **wall clock**. Every track is tail-padded with silence to that clock and nothing stops a recording when the channel empties, so a table that played for two hours and forgot to stop for another fifty had a complete, correctly attributed transcript rejected for covering "only" 58%. This release made it worse rather than introducing it: the guard predates v4.0.0, but admin retry used to pass `duration=0`, which skipped it — so a long-tailed session that failed could at least be recovered. #421's `derive_session_duration` arms the guard on exactly that recovery path. Coverage is now measured against how far into the session the tracks carry audible speech. ## Verification - 1,369 backend tests, 211 bot tests, ruff clean - Migrations rehearsed against production data - Live eval run recorded against the real session fixture; measured accuracy-neutral versus the pre-milestone baseline (n=4 each). Extraction is 0.818 with zero variance on both sides, so the observed movement is compose sampling, which is by design (#232, #423) - A full acceptance-criteria pass over all 30 issues, verified by mutation rather than by reading — it found a dead validator check whose test could not fail, a dedupe that preferred hallucinations over verified beats, an unbounded recursion that killed the test runner, and the default transcription path having no regression coverage. All fixed in this branch. ## Known, filed, not blocking Two defects found while designing the synthetic-audio test harness (#433), both verified in code, both in the v4.0.1 milestone: - **#432** — #425's uncaptured-member fix is overridden. `speakers` is never narrowed after `drop_silent_tracks`, so a backend-dropped silent speaker is still proposed as having *spoken*. Only reachable on the backend-drop route (an older recording, a hand-placed directory, a bot a version behind) — which is precisely the route #425 exists for. - **#429** — backups are written by `pg_dump` 17 against a PostgreSQL 16 server and cannot be restored by the host's own tooling. Relevant to anyone taking the CHANGELOG's advice to snapshot before applying these six migrations: take a filesystem or VM snapshot, not just the automatic dump. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Attribution was settled twice — half in code, half in the prompt — and the two
halves disagreed.

`_apply_character_names` relabelled linked players with a character name before
building the transcript, producing a *mixed* transcript where some lines carried
character names and others Discord handles. The prompt then still instructed the
model to "use character names (not player names)", asking it to perform a rename
across thousands of lines that nothing could verify. A separate block told it the
GM "may also voice NPCs, so use context to distinguish narration from roleplay"
— an explicit invitation to reassign the speaker of a line by guesswork.

Everything now happens in code, before the model sees anything, so the model is
never asked to decide who said something. New `speaker_service` resolves each
track owner once and produces a legend the summariser is given; the beat
validator (#333) will consume the same structure.

These were filed as three issues but they are one root cause: `tracks` carried
only a display name, so ownership was thrown away one line after the bot had
encoded it in the filename. Fixing that fixes all three.

- **Ownership is authoritative (#342).** Segments now carry `track_owner_id`,
  set from the track we sent rather than the transcription server's echoed
  speaker string — which previously took *precedence* over the known owner, so a
  server running diarization or normalising to `SPEAKER_00` could silently
  relabel an entire track and break character mapping with it. On the session
  endpoint, where the server merges across speakers and no per-file attribution
  survives, labels are mapped back to their track and an unrecognised label now
  fails loudly instead of becoming the literal string "Unknown".

- **Labels are unique within a session (#344).** Two members may legally share a
  Discord display name; both used to collapse into one transcript speaker, and
  both mapped to whichever `character_map` entry won a last-write-wins dict
  insert. Uniqueness is also what makes the session-endpoint mapping above
  unambiguous. Names now prefer the guild nickname over the global name, matching
  what the live recording dashboard already showed — a player whose nickname is
  their character name kept losing that identity — and a track missing from
  speakers.json no longer leaks a bare Discord snowflake into the transcript for
  the model to read as a person.

- **The GM is always `GM` (#329).** Previously a GM whose member row carried a
  character name had *all* their narration relabelled as that character, while
  the GM context block still named their display name — leaving the prompt
  pointing at a name that appeared nowhere in the transcript.

The useful half of the old GM guidance survives in the legend: a GM speaking
across consecutive lines is usually worldbuilding and deserves prominent
coverage. Only the guessing invitation is gone.

Also adds the two things the summarisation system prompt was missing, since the
prompt assembly was being rewritten anyway: it now explains the
`[HH:MM:SS] Speaker: text` format, and asks for events in the order they
happened rather than reorganised by theme. It previously did neither, while
"Cover: key events..." actively invited thematic ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in the pipeline knew how much context it had. Prompts were assembled and
sent in hope, and nothing checked afterwards whether the server had actually
consumed the whole thing. #293 added a guard for responses truncated at the
*output* cap; this is the input side of the same problem, and the more dangerous
one — an over-long prompt can be silently front-truncated, taking the system
prompt and the speaker legend with it, and summarising the tail of a session as
though it were the whole thing.

`LLMConfig` now carries `context_tokens`, resolved as: an explicit admin setting,
then a known window for the model id, then a conservative 32k default. The
direction of the fallback is deliberate — under-estimating costs a few extra
chunks, over-estimating causes silent truncation, so an unknown model never
resolves optimistically.

Self-hosted llama.cpp is the case that most needs the explicit setting and the
one this project already got wrong: its usable window is the launch `--ctx-size`
divided by `--parallel`, so `--ctx-size 131072 --parallel 2` gives 65,536 per
slot — and generation shares that slot with the prompt. Nothing outside the
server can infer it, so the admin panel now has a field, with the arithmetic
spelled out rather than left to be rediscovered.

**Ollama's `num_ctx` is now set (#337).** It was never set anywhere in this
repository. Ollama's default context is 2048-4096 tokens and it truncates
silently — no error, no warning, a plausible-looking answer built from a
fraction of the input. Every Ollama deployment had been summarising roughly 2-3%
of a session and presenting it as the whole thing. Ollama is the most likely
local backend a self-hoster reaches for, which made this the quietest failure in
the product.

Both Ollama paths now also compare the reported `prompt_eval_count` against the
estimate. That check warns rather than raises, on purpose: a provider that caches
a prompt prefix legitimately reports only newly evaluated tokens, so a low count
means either "truncated" or "mostly cached" and from here those are
indistinguishable. Making it a hard check needs the provider abstraction to
declare caching behaviour (#351).

Foundation for #331 — the summariser now *knows* when a prompt is too large and
says so, but still sends it. Chunking is the next commit; this is the measurement
it needs to size windows from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`summarise()` sent the entire transcript in one prompt, with no chunking
anywhere in the path. A 3.5 h / 5-speaker session is roughly 55k tokens against
a 65,536-token slot that generation shares — so the design was out of budget at
exactly the workload it was built for, and on backends that truncate rather than
reject, the model silently received the tail of the session and summarised it as
though it were the whole thing.

Oversized transcripts now map to consecutive windows and reduce back to prose.
Windows are sized from the provider's declared context window rather than a
constant, so one code path is correct on a 9B local model with a 65k slot and on
a 1M-context hosted one, where the whole transcript collapses into a single
window naturally.

The load-bearing property: **chunking here is a context-management device, not
an ordering device.** Windows are consecutive and are recombined in code, so the
model never sees the whole session at once and is never in a position to reorder
it. That distinction is the answer to the reasonable objection that chunking
scrambles chronology — it does, when you summarise pieces to prose and ask a
model to stitch them, which is not what this does. Tests assert that every line
survives in its original order and that notes reach the reduce step in window
order.

Details worth knowing:

- Splits on line boundaries only. Handing a model an unattributed fragment of a
  line would undo the attribution work in #329.
- Two lines of overlap between windows, so an exchange spanning a boundary is
  visible from both sides.
- Every window repeats the campaign header and speaker legend. An excerpt read
  without the legend would have to guess who its speakers are.
- Timestamps are read only to label a window's range, so a transcript a GM has
  hand-edited into another shape still chunks — just without range labels.
- The reduce step re-checks its own budget and folds once more if a very long
  session produced too many notes, rather than committing the same overflow it
  exists to prevent.
- Windows are summarised sequentially. A local llama.cpp server has a small
  fixed slot count, so firing them all at once would queue anyway or trip a
  hosted rate limit; provider-aware concurrency belongs with #356.

Also fixes the prose output caps the same functions were carrying (#338).
Anthropic and OpenAI were pinned at max_tokens 1024 with no stop_reason check,
so a summary at the long end of 3-6 paragraphs clipped silently; llama.cpp had
no cap at all and, on the prose paths, never disabled thinking — the v3.10 fix
covered only json_mode, leaving the largest prompts in the product free to spend
the slot on reasoning and return clipped prose as the session summary.

Ruff caught `_reject_if_truncated` being used in audio_service without an
import — the tests passed because every provider is mocked, so it would have
surfaced as a NameError on the first genuinely truncated response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bot_upload_audio` had no state guard, and `process_audio` unconditionally
overwrites transcript and summary and delete-reinserts every highlight. The
trigger is not exotic: a GM who stops recording at the break and starts again
for the second half submits the same session twice. The second run destroyed the
first half's transcript, any edits the GM had made to it, and every curated
highlight — and because the bot wrote to a fixed `{session_id}/{user_id}.wav`
path, it overwrote the source audio too, so no reprocess could recover it.

Three independent holes, closed at three layers:

- **Intake refuses.** A session whose `transcript_updated_at` is set now returns
  409 rather than queueing. That field is the right marker because it is set
  whenever the transcript is written *or edited*, so a hand-edited transcript is
  protected as firmly as a generated one. `force=true` replaces deliberately.

- **Highlights survive.** The delete before reinsert was by `session_id` alone.
  It is now scoped to rows that are neither approved nor GM-edited, so
  curation — including manually added highlights — survives a reprocess.

- **Takes no longer collide.** The bot writes a second take to
  `{session_id}-take2/` instead of over the first. The first take keeps the bare
  session id, so nothing about the backend contract or the handoff sweep
  changes.

The bot distinguishes the refusal from an outage. A 409 raises
`AudioAlreadySubmittedError`, and the GM is told their existing transcript was
kept and where this take is saved — rather than "could not be submitted… an
admin can retry", which would be both wrong and alarming for what is actually
the system protecting their work. The bot never sends `force`, so it cannot
destroy a transcript at all; that requires a deliberate admin action.

Deliberately **not** bumping BOT_CONTRACT_VERSION. The request change is
additive, and while a previously-202 case can now return 409, an older bot
handles that by telling the GM it could not submit — which is true, harmless,
and the safest possible degradation. Forcing every self-hoster into a lockstep
image upgrade for a slightly wrong error message in a rare case is
disproportionate; both images move together in v4.0.0 regardless.

This was moved into v4.0.0 from v4.1.0 because it sits in the path of the
accuracy work: developing the summarisation re-architecture means re-running
`process_audio` against real sessions repeatedly, and until now every one of
those runs was destructive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The summary was free prose with no timestamps, no citations and no required
evidence, so nothing let a reader — or any downstream check — verify a claimed
action against the transcript. That is why a total corruption of the input
(#320) produced output that read fine to a human for months.

Both reported failure modes are now checked rather than trusted:

- **Chronology** is a `sorted()` over beat start times, applied in code. The
  model is never asked to order the session, so it is never in a position to
  reorder it.
- **Attribution** is verified against the transcript. A beat names its actors
  and cites the lines that support them; a beat whose actor never spoke a cited
  line is flagged rather than believed.

Extraction runs over the same time windows as #331 and emits JSON beats
(`t_start`, `t_end`, `actors`, `type`, `summary`, `evidence`). A pure-code
validator then checks that every cited timestamp is a real transcript line, that
every actor spoke one of those lines, and that the range lies inside the session.
Composition is given the validated, ordered beats — never the raw transcript —
so the prose cannot introduce an event nothing verified.

GM narration is handled explicitly rather than guessed at. When the GM describes
something a character did, the beat is marked narrated and the actor must appear
*in the text* of the cited GM line. That is the direct replacement for the old
prompt's "the GM may also voice NPCs, so use context to distinguish narration
from roleplay", which invited exactly the misattribution being reported: a beat
crediting a player because the GM happened to be talking nearby is now flagged,
and there is a test for that case specifically.

The validator being pure code is the point, not an implementation detail. It
behaves identically on a 9B model on a self-hoster's CPU and on a frontier
hosted model — a small model loses more beats to validation, but it cannot
produce a confidently wrong summary. That is what makes a local-model tier
honest, and it is why correctness here does not ride on model size. It also
generalises the one pattern in this codebase that already worked:
`extract_highlights` has always validated its quotes by substring match.

Failing beats are flagged and logged, not silently dropped. Dropping them would
trade one invisible failure for another — a real event with a bad citation would
vanish with nothing to show for it.

Degrades rather than fails. If extraction returns nothing, raises, or produces
no beat that validates, summarisation falls back to the prose path from #331 and
logs why. A provider that cannot hold a JSON shape should get a worse summary,
not an error. The beats path is also skipped entirely when no speaker identities
are available, because with nothing to check an actor against, verification
would be verification in name only.

33 validator tests and 8 wiring tests, covering the misattribution case, the
invented-citation case, GM narration in both directions, ordering independent of
extraction order, and every fallback route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The transcript was a single flat Text column, and four separate places re-parsed
it to recover structure that had just been thrown away one step earlier: member
erasure regex-parsed the blob to find a speaker's lines, highlight quotes were
substring-matched against it, lore chunking cut it by character offset (so a
chunk could open with an unattributed fragment), and the GM free-edits the same
text in the UI — where a hand edit could silently break the erasure regex.

That made the *rendering format* a load-bearing data contract: changing how a
line looks risked breaking erasure. Rows make the structure the source of truth
and the text a view of it.

`transcript_segments` carries one row per utterance with `seq`,
`track_owner_id`, `speaker_label`, start/end seconds and text. Owner is stored
separately from the rendered label on purpose — the label is derived from the
owner and never the other way round (#342), which is what keeps a server-side
relabelling detectable rather than silently authoritative.

Additive by design. `sessions.transcript` is untouched and remains what the UI
renders and the GM edits; rows are replaced wholesale on a reprocess because
they describe one transcription run, while the transcript and summary may carry
GM edits and are guarded separately (#397).

Existing sessions are **not** backfilled. The structure an old blob would need
was never captured, and a best-effort re-parse would produce rows that look
authoritative and are not — which is precisely the class of failure this table
exists to end.

Also adds the deterministic tie-break half of #345. Sorting by start time alone
left two segments sharing a second in whatever order the producing path happened
to emit them — filename order on the VAD path, server order on the session
endpoint. That is newly load-bearing: beat evidence cites timestamps, so an
unstable render would make citations unstable too.

The *other* half of #345 — rendering `[start-end]` ranges instead of a single
stamp — is deliberately deferred rather than done here, and I have noted why on
the issue. The single-stamp form is now parsed back out by `beat_service` to
verify that a claimed actor spoke a cited line, so changing it means changing
that parser in lockstep. The review already downgraded ranges to cheap polish
once capture was fixed; that is not worth spending against a citation format the
accuracy work now depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member erasure found a speaker's lines by matching the rendered transcript
against their character_name or display_name. Neither is necessarily the label
they were rendered under: the label is resolved per session from their guild
nickname, and gets a suffix when it collides with someone else's. So an erasure
request could complete, report success, and leave the member's words in place.

The same lookup decided which quote highlights to delete, with the same blind
spot, and the same collision could cut the other way — two members rendering
under one label meant erasing either destroyed both.

Transcript rows carry track_owner_id, the Discord account whose audio file the
words came from, so erasure now keys off that. Sessions recorded before rows
existed keep the label path; it is never backfilled, so there is nothing better
to match on there.

Two things the migration must not break, both pinned by tests:

  * A GM's hand-edits are never re-rendered over. We re-render from rows only
    when the stored text is still byte-for-byte the render of those rows. Once
    it has diverged we scrub the text the GM has — using the label learned from
    their rows, so the erasure is still complete.
  * A member with no verified Discord link has no authoritative key, so the
    label path still runs for them rather than being skipped because rows exist.

Where the member has a link but a session's lines carry their name under a
different track, we log rather than guess: matching the label anyway could
destroy a different member's words, and an account change is worth a human
look.

Also closes a hole #335 opened: the retention sweep cleared sessions.transcript
but left the rows holding the same words, so an expired transcript was still
sitting in the place the pipeline reads from.

The line format now has exactly one definition, render_transcript_line, which
was the point of moving structure into rows — beat_service parses that shape
back out to verify cited evidence, and it should not be spelled out twice.

Also reformats five test files added earlier on this branch: CI runs
`ruff format --check` over webapp/backend, and only `ruff check` had been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
extract_highlights does the hard half right: a quote is dropped unless its text
appears verbatim in the transcript, so a hallucinated line never reaches the
quote board. But the speaker and timestamp_ref stored beside it were
LLM-asserted strings that nothing checked. A quote could be genuine, word for
word, and captioned with the wrong player's name — and these are published to
the whole group, where a misattribution is visible and awkward in a way a
summary error is not.

anchor_highlights uses the part already verified — the words — as the key:
find the segment whose text contains the quote and take the speaker and
timestamp from that, discarding what the model claimed. Attribution comes from
the track the audio came from, the same rule as everywhere else in the pipeline
(#342). A wrong caption is corrected rather than dropped: the quote is good.

A quote matching no single segment is dropped. The hallucination filter matches
against the transcript with newlines collapsed, so a "quote" stitched from the
ends of two people's lines passes it — and there is no single speaker who said
it, so publishing it under either name is a misattribution by construction.

Two people saying the same words is genuinely ambiguous; the model's own
timestamp breaks the tie, then its claimed speaker, then the earliest match.
Counted separately so a run full of them is visible rather than silently
resolved.

Anchoring runs after the empty-highlights branch, not before, so dropping every
quote is not mistaken for "extraction returned nothing" — which would take the
keep-the-previous-run path and leave stale captions in place.

Sessions with no segment rows pass through untouched. Rows are never
backfilled, so anchoring an old session against nothing would delete a working
quote board on upgrade.

The correction counts are logged per session as a model-quality signal, which
is what tells us whether a smaller self-hosted model can hold attribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JSON output on the Anthropic path was steered by prefilling the assistant turn
with "{". That was removed on Claude 4.6 and the entire 5-family and now returns
a 400 — so an operator who configured any current model broke every structured
feature in the product at once, with an error reading like a Quest Board bug.
The pinned default snapshot still accepted it, which is exactly why this sat
latent.

The replacement is a schema rather than a nudge, and it goes further than the
Anthropic path: all four providers support constrained output, so
generate_structured_text now takes an optional json_schema and each provider
spells it its own way. Two of those spellings look interchangeable and are not
— llama.cpp puts the schema directly under response_format, OpenAI nests it
under a further json_schema key — which is the same kind of runtime-only,
wrong-backend failure the prefill was. They live in one function, with a test
per provider pinning the exact wire shape.

On llama.cpp and Ollama a schema becomes a decoding grammar, so a malformed
response is impossible rather than merely discouraged. That matters more than
the Anthropic fix: it is what lets the accuracy-critical paths run on a small
self-hosted model instead of degrading to the unverified prose fallback every
time a 7B model fumbles a brace. Beat extraction and quote highlights — the two
paths the v4 accuracy work rests on — now declare their shape. The other
callers have no fixed shape and keep the tolerant parser, unchanged.

Both shipped schemas are checked by a test against the strictest provider's
rules (additionalProperties false everywhere, every property required), since
violating either is rejected at request time on a provider the developer may
not be testing against.

Documents provider selection, the model field, and the context-window setting
in OPERATIONS.md, including the note that dated Anthropic snapshots and current
ids do not behave identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#271 established the pattern — budget context per position, truncate from the
middle so the end of a summary survives — and the workbench tools follow it.
Four places did not, and each grew with the campaign until the prompt outgrew
the window. That failure is silent: the endpoint front-truncates and answers
confidently from whatever fitted.

  * Bot /ask sent ten complete session summaries plus *every* approved lore
    entry at full body length. Now: summaries truncated middle-out, wiki
    entries capped in count and body, and the query itself capped so a campaign
    wiki is no longer read into memory in full to build one prompt.

  * Session-title suggestions included storyline.body whole — the concatenation
    of every session's chapter, ~35k tokens by session 50 — under a comment
    claiming everything there was truncated.

  * lore_match_category compared each candidate against every approved entry of
    its type: bounded per entry, unbounded in count, ~45k tokens at 200 NPCs.

  * Single-pass lore head-truncated the transcript at 16,000 characters, so
    every NPC, place and faction introduced after roughly the first twenty
    minutes of a session was invisible to extraction. This was the exact
    pre-#271 mistake, still live in a non-default mode.

Two choices worth naming.

Which entries get dropped matters as much as that they are. Ordering was
alphabetical, so a campaign with 200 NPCs answered every question — and matched
every new candidate — against the ones whose names begin with A. Both paths now
rank by word overlap with what is actually being asked. It is a crude signal,
needs no index, and is deterministic; it is also enormously better than
alphabetical. In lore matching this is not just context economy: a dropped
entry becomes a duplicate wiki page for a character who already exists.

For extraction, no contiguous slice is right. Head truncation loses the second
half of the session, middle-out loses the middle, and entities are introduced
throughout — so sample_evenly takes an equal share of each of eight spans.
The last span contributes its close rather than its opening, or the very end of
the session falls off the back of the final block and is lost exactly as
before, just less obviously. (Caught by a test, not by reading it.)

_truncate and _truncate_keeping_ends move down to llm_service: #340 needs them
in audio_service, and importing upward from there would be circular.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`[HH:MM:SS] Name: ` costs 10-14 tokens per line against a transcript of
1,000-2,500 lines, all of it spent repeating who is talking, in the place where
prompt size decides whether a session has to be chunked at all. Speech clusters,
so merging consecutive same-speaker lines removes both the repeated stamp and
the repeated name.

Measured on seven real production transcripts (672k chars): 24-30% fewer lines,
10.9% fewer characters, ~1,900-5,100 tokens saved per session. That is well
under the ~35% the issue estimated — the estimate assumed speech clusters more
heavily than it does. Recorded on the issue rather than left as a guess.

Two things the measurement changed.

Runs are capped at 30 seconds. Without a bound, a five-minute GM monologue
collapses to one line at one timestamp: every beat drawn from it shares a start
time, so ordering inside it is lost and cited evidence can sit minutes from the
moment it describes. Three existing beat tests failed on exactly that shape,
which is what surfaced it. The cap costs about one percentage point of the
saving and keeps timestamps honest to finer than beats work in.

The second half of the issue — coarsening timestamps into per-block headers —
is deliberately not done. Under block stamps a citation can only say "somewhere
in this 30 seconds", across several speakers, so "this actor spoke this line"
degrades to "this actor spoke near this line". That is the verification
#332-#334 exist to provide, and it is worth more than the tokens.

Compaction applies to the prompt, not to storage: session.transcript is
untouched, and everything downstream of summarise() — window splitting, beat
extraction, beat validation — works from the same compacted text, because the
validator resolves cited stamps against it and indexing a different rendering
than the model was shown would fail every citation.

beat_service._LINE_RE becomes LINE_RE: it is the shared definition of the line
format now, and re-declaring that regex per consumer is the coupling #335 set
out to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reconciles tonight's hotfix with the v4 refactor. The conflict was structural
rather than semantic: v4 replaced (wav_path, display_name) with the Track
dataclass so a span carries its owning track's identity (#342), while the
hotfix added language pinning and per-span retry against the old signature.

Both survive. _detect_track_language and _transcribe_span_resiliently now take
a Track, the detection probe is cut as a Track derived from its parent so a
probe's segments are attributed to the same person as the rest of the track,
and the per-span Track construction that #342 introduced is preserved.

1075 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to #347. Detection is now reliable enough to be the default — it
reads the longest stretch of speech in a session rather than each 1-3 second
clip — but it is still a heuristic, and the failure it guards against is worse
than the crash that exposed it.

The 500 was the lucky outcome. WhisperX identified three seconds of English as
Icelandic at 0.95 confidence and raised because it had no aligner for Icelandic.
Had it landed on German, Spanish, Dutch or French — all of which it *does*
align — the same clip would have come back as confident, silently wrong text and
flowed into the transcript, the summary and the wiki with nothing marking it.

A GM knows what their table speaks, and an answer beats a guess. So:
campaigns.transcription_language, an ISO 639-1 code, passed straight through to
transcription, where supplying it skips detection entirely.

Three decisions worth stating.

**The default stays "detect".** A default of `en` would silently mistranscribe
every non-English group until somebody found the setting — a quiet failure
substituted for a loud one, which is the wrong direction and the opposite of
what #347 was for. Existing campaigns are untouched and behave exactly as
before.

**The choice is an allow-list, not free text.** Anything WhisperX has no
wav2vec2 aligner for is a hard 500 on every span of every future session. An
unvalidated field would let a GM configure precisely the bug we just fixed, so
the value is checked against the set alignment supports and refused at the API
boundary — a validation error while they are looking at the form, rather than a
lost recording three weeks later. The same list is served to the UI so the
select can never offer what the backend would reject.

**Casing and region suffixes are accepted.** `EN`, `en-GB` and `en_US` all
normalise to `en`; rejecting those would be pedantry rather than safety.

The frontend test mocks the campaigns API module explicitly, so the new import
arrived as undefined and took 24 tests down with it. Mock updated — a reminder
that an explicit module mock is a list that has to be maintained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three issues that landed together.

**#421 — a reprocess is guarded like a first pass.**
Both retry endpoints queued process_audio with duration 0. My issue said that
was an oversight; it was not, and the docstring says so — it let an admin re-run
an already-suspect recording without being blocked. But 0 does not mean "proceed
anyway", it means *both* invariants return immediately, including the
transcript-coverage one, which has nothing to do with whether the source tracks
are suspect. And nothing told the GM the checks had been skipped.

The duration was never really unknown: every track spans the session by
construction since #320, so the longest track is the session.
derive_session_duration recovers it and both endpoints — the admin one had the
same hole, unmentioned in the issue — now pass it. A retry that fails with
"this track covers 400s of a 4639s recording" beats one that silently rebuilds
the same scrambled transcript.

**#289 — relationship proposals are campaign-scoped.**
The task took a session_id only to resolve its campaign, so an entry with no
linked session scheduled nothing at all: a GM writing an NPC up by hand got no
relationship suggestions, ever. Now keyed on campaign, which also coalesces two
sessions approving concurrently into one pass instead of two over the same set.

The session was not purely vestigial, though — it also drove a
"[appeared this session]" flag and a prompt directive to prioritise pairs
involving those entries. Dropping it outright would have left a mature wiki as
a flat list of a hundred entries with no reason to prefer any pair, so
suggestions would get *worse* the more lore a campaign accumulated. The steer is
restored in a better form: approvals are accumulated in a Redis set during the
debounce window and marked "[newly approved]". Approval is the trigger, so what
was just approved is a truer focus than the session ever was — and it works for
the hand-written NPC, which the old flag could not serve at all.

Recorded before the debounce check, not after: approvals two through eight of a
burst land inside an open window and return early, so recording later would make
seven of eight entries invisible to the pass they were folded into.

create_bot_relationship_proposals turns out to take a session_id it never uses,
against a model with no session column — so there was no attribution to
preserve. Left in place rather than widening the diff; worth removing.

**#232 — reasoning models degrade legibly.**
The Test LLM probe capped output at 16 tokens, which a reasoning model spends
entirely on hidden reasoning. The issue claimed this reported a failure; it did
not — it returned empty and the UI showed "Reachable" with no reply, which is
its own kind of unhelpful. Probe budget raised, thinking disabled where the
provider exposes a toggle (llama.cpp chat_template_kwargs, Ollama think), and a
reasoning-only response now reports success instead of blankness.

An empty response with finish_reason=length now says the model exhausted its
output budget and how to fix it, rather than blaming json_object support.

Thinking is deliberately NOT disabled on prose flows: those callers pass
max_tokens=None precisely so a reasoning model can think before writing, and
turning that off would be an unreviewed quality trade the transport layer has no
standing to make. Where a prose caller does set a finite cap, the finish_reason
message now tells the operator what happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A misattribution vector created by fixing the previous one. Since #320 the sink
pads every track to the full session length, so a player who joins voice and
never says a word now produces a full-length *silent* file rather than no file
at all. Whisper hallucinates repeated phantom text on silence — one of its
best-documented failure modes — and attribution here is per track, so those
invented lines would be published under that person's name. Someone who sat
quietly all evening would be credited with things they never said.

Two independent defences, because the bot cannot be the only one.

**Bot.** `_speech_bytes` already counts decoded voice packets separately from
the silence written to hold the clock, so whether anyone actually spoke is a
number we have, not something to infer from the audio. Below half a second of
speech the track is dropped before upload. The floor is not zero on purpose:
DTX clients emit a trickle of comfort-noise packets while their owner is silent,
and a zero check would read that as speech.

Dropped speakers are merged into `uncaptured_member_ids` rather than simply
omitted — "present, never captured" is a real state the GM already sees as
attendance source `in_channel_silent`, and quietly dropping them would make
someone who sat in voice all session look like they were never there. A session
where nobody spoke now says so instead of submitting nothing.

**Backend.** `drop_silent_tracks` measures peak amplitude across the whole file
— sampled in windows spread through it, never the head, because every track
opens with silence unless its owner spoke first. This catches an older
recording, a hand-placed directory, or a bot one version behind. An unreadable
track is kept, not dropped: unreadable is not silent, and deleting a speaker
over an IO error is the worse mistake.

The `_peak_amplitude` full-file scan the issue asks for already landed with the
capture fix.

Both sides pin the case that matters most: one short utterance is enough. In the
2026-08-26 session a player spoke for 163 seconds out of 4639 — 3.5% of the
recording, every word real. A guard that dropped them would silently delete a
participant from the transcript, which is the same failure wearing the other
hat.

No contract bump: `uncaptured_member_ids` already exists in the bot API and is
only being populated more completely.

Note for later: `ruff format` over `bot/` reformats the whole component (19
files, ~750 lines) because CI only lints `webapp/backend/` and `scripts/`. Run
it on named files there, or not at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#339 claimed that on llama.cpp a schema becomes a decoding grammar, so a small
local model cannot emit malformed JSON. Tested against a real endpoint, that was
false for the shape actually shipped.

The flat form — schema directly under response_format — is **silently ignored**.
An adversarial prompt (asked for prose, a value outside an enum, and an extra
field against additionalProperties: false) walked straight through and came back
as English prose. An impossible schema returned HTTP 200 instead of erroring,
which is the proof: the server never parsed it. Every json_schema call in
production was buying exactly nothing over plain json_object mode.

The OpenAI-nested form is enforced on that same endpoint. The identical
adversarial prompt returns {"mood": "angry"} — schema-conformant, enum honoured,
no extra field, no prose. An impossible schema 400s with a grammar-compilation
error.

The reason is that "llama.cpp" in the wild is often not llama.cpp. This endpoint
reports role=router with max_instances=4: a proxy in front of llama.cpp that
implements the OpenAI contract and not the native one. llama.cpp's own README
documents the flat form, and #339 took it on that authority — which is correct
for a vanilla llama-server and wrong for what a self-hoster is often running.

Both spellings are now sent. A server reads the key it knows and ignores the
other; both were verified to enforce, and both 400 on an impossible schema.
Guessing one and being wrong costs the guarantee entirely and silently, which is
precisely what happened.

The test that pinned this passed the whole time. It asserted the flat shape
because that is what the code did, and both came from the same README. A unit
test on a wire format can only confirm the belief that produced it — so the
replacement names the endpoint the shape was verified against rather than citing
documentation, and pins that the two spellings cannot drift apart.

Also worth recording from the probe: plain {"type": "json_object"} is not
enforced on this endpoint either. The tolerant repair parser has been doing all
the work, which is why nobody noticed.

Ollama's format-as-schema shape remains unverified — no endpoint to hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The transcription server can skip a track it cannot read and still answer 200
with everyone else's speech. Nothing downstream noticed: empty segments are
filtered silently, and the merged transcript looks complete because it *is*
complete for everyone in it.

From the GM's side that reads as misattribution rather than absence. The missing
player's actions survive only in other people's reactions to them, so the
summary credits whoever reacted — which is the complaint that opened this
milestone.

The tension worth naming: #348 means every track reaching transcription has real
speech, so an absent speaker is genuinely anomalous — but a player who only says
"yeah" a few times can still transcribe to nothing, and failing a whole session
over them would be the quiet-player mistake in a new place. So the check uses
what VAD already measured: below 30 seconds of detected speech an absence is a
warning naming the player; at or above it, the track was skipped, not quiet, and
the session fails naming who and how much they said.

The check lives in transcribe_session_vad rather than the caller because that is
where per-track speech duration exists, and that duration is the entire
difference between the two cases. The non-VAD path has no such measure — the
whole track went to the server — so any absence there is treated as the failure
it now is.

This also replaced a warning in transcribe_session whose premise had expired: it
said a track with no segments is normal because someone may have joined voice
and never spoken. Since #348 those tracks are dropped before transcription, so
what reaches this point should always produce something.

Four language tests failed on the new check, correctly: their fakes returned no
segments for forty seconds of speech, which is precisely the dropped track this
refuses. A test double that models an impossible response will eventually assert
that the impossible is fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#287 documented four legitimate policies for "no LLM is configured" and left
each task to implement its chosen one by hand. That fixed the eight behaviours
that existed but not the thing that produced them: every task still hand-writes
its own preamble, so nothing stops a ninth appearing.

`@llm_task` is the enforcement point. It absorbs the parse-id / open-session /
load-row / resolve-config preamble and owns the "what happens when there is no
LLM" branch, so a new task declares a policy instead of reimplementing one.

The config is handed to the body as a deferred async resolver rather than a
value. That is the part worth explaining. The six tasks the issue lists as
sharing one skeleton do not actually resolve the config at the same point:
generate_journal_entry checks "no summary" and "note already exists" first,
generate_lore_entry_summary checks for an empty body, propose_lore_relationships
checks the LLM before it loads anything at all, and the workbench core reports an
unknown tool_id first. Hoisting the resolution to the top of the body reorders
those guards, and the reordering is not cosmetic: on an install with no LLM,
every session with nothing to do would start logging "LLM not configured" at
WARNING — inverting a quiet no-op into a standing warning, which is the exact
noise #287 set out to remove — and the workbench would tell a GM their LLM was
unconfigured when what was actually wrong was the tool id.

So the body calls `llm_cfg = await llm()` exactly where it resolves the config
today. The resolver never returns None; it raises a private sentinel the
decorator catches and dispatches on the declared policy. That is stronger than
passing a value in: with a value, a body can still forget to branch on None.
Here there is no None branch to get wrong.

The sentinel is caught by exact type, never as `except Exception`, so a body
wrapping its LLM work in a broad except cannot swallow the policy — the one
failure mode that would make this silently worse than the code it replaces.
A test pins it.

`llm` is a required keyword with no default. A decorator that handed out policy 3
to anyone who omitted the argument would rebuild #287's drift one level up, just
with fewer places to look for it.

Two things resisted the decorator and stayed as they are. Policy 2 does not
generalise — "the row" is `draft.status`/`last_error` in one task and
`generation_result_service.mark_failed` in another, and both of those tasks'
cores are called directly by twenty-odd tests as `f(db, id)`, so their signatures
are pinned and nothing can reach inside them; those two adopt only the preamble
half. And `process_audio` and the lore pipeline tasks are excluded as the issue
asked, along with planning_tasks, whose LLM sites sit inside a Redis lock and
return a dict.

Seven tasks adopt it. reminder_tasks.py drops 114 lines and llm_task.py costs
212, so there is no net line reduction — the issue's fourth acceptance criterion
is not met and I do not think it should be chased. Six adopters were never going
to pay for a documented module; the value here is that the policy now has one
implementation instead of seven, and the remaining task_session blocks mostly
take no id, so their preamble is a single line a decorator would not improve.

Model imports for `loads=` are hoisted to module scope. The issue warned that
the function-local imports exist to dodge circular imports; that is true of the
service imports, but app.models.* import only app.database and sqlalchemy, and
importing reminder_tasks standalone in Celery's order registers all 42 tasks.

No existing test was edited: 1,130 passing before, 1,142 after with the 12 new
decorator tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CampaignMember.character_name` was one column, so a player was one character.
A main and a familiar, a party of two, a character who dies in session 12 and is
replaced — all of it collapsed onto whichever single name the row held, and every
line that player spoke was attributed to it. That is the misattribution this
milestone exists to fix, in a place #320's clock fix could not reach.

Characters move into `campaign_characters`, owned by a membership through a
composite FK so they cannot outlive it. A partial unique index enforces at most
one active character per member, in the database rather than in service code:
"exactly one active" is what every read path assumes, and two browser tabs would
otherwise race into two actives with an arbitrary winner in the transcript.

`session_character_overrides` records which character a member was running for
one session. Without it, correcting a member's character to reflect a swap
rewrites the past — reprocessing session 5's audio, which #421 and #397 both make
possible, would label it with the character they run now.

The five `character_*` names stay on CampaignMember as read-only properties over
the active character. That was the difference between touching three write sites
and auditing two dozen read sites, but the reason to prefer it is that a derived
property cannot disagree with the table, where a mirror column maintained by
convention eventually does. `characters` is `lazy="selectin"` because a lazy load
under asyncio raises rather than returning a wrong answer, and a campaign has a
handful of members.

## The pipeline half, which is the point

A Discord account carries every character its owner plays and audio cannot say
which one spoke a line. Labelling every line with the active character would
assert something no evidence supports — the familiar's lines filed under the
main. So with two or more characters the label leads with the player, who is what
the track actually proves, and names the character they were mainly running:
`ash (Kira)`. With one character nothing changes at all; that case still labels
by the character, and its tests were untouched.

The legend then names every character, and the beat validator accepts any of them
as a valid actor for that speaker's lines. Without that last part the feature
would be worse than useless: a beat correctly attributed to Whiskers would be
rejected as an invented name purely because the transcript label says Kira. The
alias map is scoped to characters of speakers who actually spoke a cited line, so
it does not become a hole any invented name fits through — there is a test for
exactly that.

Member erasure now scrubs every character label a person ever had, not just the
current one. A player who swapped characters mid-campaign appears in older
transcripts under the old name, and erasing only the active one would leave those
lines attributed to someone who asked to be removed.

## Migration: expand only

The migration backfills the five columns into `campaign_characters` and then
leaves them in place, unmapped. A downgrade is two DROP TABLEs and no data is
ever at risk, which is what I want on a database holding recordings that cannot
be re-made. It also sidesteps the one row shape a contract would lose — a member
who set a sheet URL but never named their character — rather than backfilling
them under an invented name that would then show up as a transcript speaker.

Verified against a real Postgres rather than the test suite, which builds schema
with create_all and never runs Alembic: full chain applies clean; the backfill
trims whitespace, copies all five fields and skips a null-character GM; the
partial index rejects a second active and permits a second inactive; deleting a
membership takes its characters; downgrade and re-upgrade leave the source
columns intact.

## Two things found on the way

Claiming an import stub now passes `link_lore=False`. It restores recorded state
rather than creating something new — the stub carries both the wiki entry id and
the exact ownership role the export recorded — and deriving the link again made
the member primary owner of their character's page, silently discarding a
recorded `secondary`. I introduced that regression and a delegate's test run
caught it.

Export goes to schema 9, adding a `characters` list per member while keeping the
flat `character_name` keys populated from the active character, so a v9 export
still restores into an older Quest Board. Imports of versions 2–8 fall back to
the flat keys unchanged.

1,130 passing before, 1,162 after. UI is the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI half of #330. A member with one character looks and behaves exactly as
before — name, pronouns, sheet link, "Edit character" — because that is what
almost every table is, and making them navigate a list to do what used to be one
inline field would be a worse product for a feature they do not use. Two or more
characters get a compact list with the active one badged and a "Make active"
next to the others.

## A permission boundary that nearly went missing

The pre-#330 member PATCH endpoint split character fields two ways: a GM acting
on someone else could set name, pronouns and the wiki link but *not* the sheet
URL or sheet notes; a player could set their sheet fields but not link an
arbitrary wiki page. Moving characters onto their own endpoints carried only
half of that across — the new PATCH kept the lore-link restriction and quietly
dropped the other direction, so a GM could edit any player's character sheet
notes.

That is a privacy regression, not a refactor. Assigning who someone plays is a
table decision; editing their notes about their own character is not. Both
directions now go through one helper on the router so the next endpoint cannot
take one and forget the other, and there are tests for each direction plus the
create path, which had the same hole.

The UI matches: a GM editing another member's character is not shown the sheet
fields at all, and the client does not send them. Rendering inputs whose values
the server discards is its own small betrayal — the GM types, saves, and watches
their edit vanish with no error.

## Also here

`PUT /sessions/{id}/character-overrides/{user_id}` moves from the campaigns
router to the sessions router. It had landed at
`/api/campaigns/sessions/{id}/character-overrides/{user_id}`, which is a
confusing path for a session-scoped action and only existed to dodge the
route-ordering rule in webapp/CLAUDE.md. Better fixed now than after a client
depends on it. The campaign check that the router was doing by hand moved into
`set_session_override`, where it belongs — pinning a character from a different
campaign the same player happens to be in would attribute their lines to a party
they were not at that table with.

440 frontend tests pass (39 in CampaignDetail, up from 30), 1,165 backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deferred half of #345. `merge_attributed_transcript` emitted a start-only
stamp, so two people talking over each other were invisible: if A speaks
00:10:00-00:10:25 and B interjects at 00:10:12, A's entire utterance prints
before B's line, and A's *reaction* to the interjection reads as preceding it.
The model has no way to recover the interleaving from ascending starts alone.

## Why not ranges on every line, as the issue proposed

#341 spent real effort cutting 10.9% of transcript characters, in the one place
where prompt size decides whether a session has to be chunked at all — and this
milestone treats self-hosted small models as a first-class target, so a window
boundary is not a cosmetic concern.

Measured on a synthetic 77-minute, six-speaker session (1,200 segments, 21.5%
of them overlapping — higher crosstalk than a typical table), compacted:

    single stamp, pre-#345      17,105 tokens
    ranges only where needed    17,690   (+585,   +3.4%)
    ranges on every line        19,728   (+2,623, +15.3%)

Universal ranges cost 4.5x what conditional ones do and would eat most of what
#341 saved. Conditional ranges put the cost exactly where the information is —
a line that overlapped nothing has an end time that tells the model nothing —
and the inconsistency is itself the signal: two stamps *means* crosstalk, which
is more informative than a uniform format the model has to reason about.

I had estimated ~10,500 tokens for universal ranges when I proposed this. The
measured figure is 2,623 on a session of this size. The conclusion holds and the
ratio holds; my per-line arithmetic was roughly 3-4x too pessimistic.

Separator is an ASCII hyphen, matching `TranscriptWindow.range_label` in the
same module, rather than the en-dash in the issue text.

## The format is a contract, so everything moved together

Written once, parsed back by compaction, again by beat-evidence validation, and
later by member erasure. Every reader now accepts both forms — not optional,
since every transcript already in production is single-stamp and both erasure
and reprocessing run over those. Where a reader needs one timestamp it takes the
start, so `build_transcript_index` keys unchanged and beat evidence still cites
a start stamp. Compaction carries a run's range as (first start, largest end)
and emits a single stamp when no line in the run had one.

`render_transcript_from_rows` applies the same overlap rule, because
`reminder_tasks` compares a re-render against the stored transcript byte-for-byte
to detect GM hand-editing. There is an integration test for that equality now,
not just an assertion that it ought to hold.

## One thing I changed after review

`mark_overlapping` stopped trusting its caller to pass start-sorted input and
sorts internally. The sweep breaks early on start order, so an unsorted caller
got *fewer* overlaps marked — silently reading as "nobody talked over anyone"
rather than failing. `render_transcript_from_rows` renders in stored `seq` order
and deliberately does not re-derive the sort, which is exactly the shape of
precondition that holds today and quietly stops holding after a later change.
It costs one sort and removes a silent-wrong-answer mode.

The deterministic tie-break half of this issue shipped earlier in the milestone;
`order_segments` already sorts on (start, track_owner_id, speaker).

1,165 passing before, 1,182 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`extract_beats` never passed `max_tokens`, so it silently inherited
`generate_structured_text`'s 2048 default — while nearly every other structured
call in the pipeline passes None. It is the one call whose response length
scales with its *input*: a window's worth of beats.

The failure mode gets worse as models get better. Window size is derived from
the provider's declared context, so a 131k-context model puts a whole 77-minute
session into one window and asks for every beat in it. That does not fit in 2048
tokens, `_reject_if_truncated` correctly refuses to trust half a JSON object, and
the entire extraction is thrown away — so beat extraction failed *hardest* on the
largest context windows, and `_summarise_from_beats` fell back to prose, which is
the path where chronology and attribution go back to being trusted rather than
checked. Exactly the accuracy floor this milestone is trying to establish,
quietly not applying.

Found by the #349 eval harness on its first run against a real session: a
77-minute transcript against qwen3.5-9B at 131k context raised
"llama.cpp truncated its response at the output cap" before scoring anything.
Nothing in the existing suite could have caught it — every test either stubs the
LLM or uses a transcript short enough to fit.

Not a reasoning-token problem: `json_mode` already sends
`enable_thinking: False`, so the 2048 was being spent on real output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in this system has ever measured whether the summarisation pipeline is
any *good*. Every check verifies that a step completed, not that its output is
correct — which is why a total corruption of the input survived for months and
was caught by a human noticing a summary read wrong. With a provider abstraction
landing in v4.2.0 and models being swapped in and out, that gap becomes permanent
unless something closes it.

`python -m evals` scores four things against hand-checked fixtures: coverage
(which known events survive into the summary), attribution (is each credited to
the right actor), chronology (Kendall tau-b over event order), and beat
validation rate (free from the code validator). Every metric returns the evidence
for its score, not just a number — a metric you cannot interrogate when it drops
is not much better than no metric.

Chronology is `None`, not 1.0, when fewer than two events are covered. A harness
that reports a perfect score for having measured nothing is worse than one that
does not run.

## The fixtures are adversarial on purpose

Three of the four synthetic fixtures are deliberately *wrong* — scrambled order,
a misattributed actor, a hallucinated citation — and CI asserts the scorers catch
each one. A scorer that silently always returns 1.0 would pass a corpus of
correct fixtures, and those are the two failure modes that opened this milestone.

## Real player data does not go in git

`fixtures/private/` is a self-ignoring directory (`*` plus `!.gitignore`), and it
holds a real hand-verified session: the first recording made after the #320 clock
fix. The issue's own criterion says "no real player data unless explicitly
consented", and consent to be *recorded* is not consent to be committed into a
repository that underpins a hosted product and runs through CI. The GM consented
to their campaign being used, so the realism is available locally while five
other people's verbatim speech stays out of the tree. `history.jsonl` records
scores only, never content.

## What it found on its first real run

A production bug that no existing test could have caught, fixed in 3bf85b3: beat
extraction was silently capped at 2048 output tokens and failed hardest on the
largest context windows.

Then, with that fixed, three consecutive runs over the same session, same model
(qwen3.5-9B at 131k), same prompt:

    run   coverage  attribution  chronology(tau)  beat validation
      1      0.091        0.000              n/a            0.240
      2      0.727        0.375            0.857            0.824
      3      0.545        0.667            1.000            0.765

Coverage varies 8x across identical inputs. That is the headline finding, and it
has a direct consequence: a single run is not a measurement, so "we changed the
prompt and it got better" cannot be claimed from one number — which is precisely
the claim this harness was built to make possible. Recorded in history.jsonl
rather than described, so the next person can check whether it improved.

Three runs is a small sample, but the spread is far too wide to be sampling
noise. Filed separately rather than fixed here.

Also: `QB_EVAL_GIT_SHA` overrides SHA detection. The normal way to run this is a
container with the repo bind-mounted and no `git` binary, which recorded every
run as "unknown" — and a score you cannot attach to a commit cannot answer the
question the history file exists to answer.

1,182 passing before, 1,202 after. CI gets an `evals` job that needs no database
and no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The #349 harness measured summary coverage varying 8x across three runs on one
real session — 1 of 11 events to 8 of 11, same transcript, same model, same
prompt, with nothing anywhere reporting a problem. A GM who drew the bad run got
a summary missing almost everything and no reason to doubt it.

Cause: this module sent no sampling parameters at all, so every call ran at the
server's default, which is tuned for creative writing. Extraction against a
fixed schema has one right answer in the transcript, and variety can only move
away from it — the same argument the module already makes for `enable_thinking`,
which it disables under json_mode for exactly this reason and leaves alone for
prose. Sampling now follows that boundary.

## Measured, not reasoned

Against the dev llama.cpp server, before: three identical beat-extraction
requests returned three different bodies. With `temperature: 0`: five identical
requests returned five identical bodies. `top_k: 1` was also tried and is
redundant — temperature 0 is already greedy here and produced a byte-identical
response, so it is not shipped.

End to end on the real session, three runs each side:

    before (server default)          after (temperature 0)
    coverage  attrib  beats          coverage  attrib  beats
       0.091   0.000  0.240             0.636   0.571  0.750
       0.727   0.375  0.824             0.545   0.500  0.750
       0.545   0.667  0.765             0.545   0.333  0.750

Beat validation is now identical across all three runs, where it previously
ranged 0.240-0.824. Coverage spread falls from 0.636 to 0.091, and the floor
rises from 0.091 to 0.545 — the catastrophic run is gone.

## What is left, and why it is left

Coverage and attribution still move a little, because they are scored on the
composed prose, and compose runs with `json_mode=False` — deliberately still at
the operator's sampling, per #232. So the residual variance sits exactly where
this change did not reach, which is the result behaving as designed rather than
a partial fix.

That does raise a real question: whether an event surviving into the summary is
a stylistic matter at all. The beats are already validated when compose runs, so
a validated beat that does not reach the prose is information loss, not voice.
Left open rather than widened into here.

## Hosted providers are deliberately untouched

OpenAI's o-series rejects any temperature but the default, and Anthropic's
extended thinking requires 1 — so sending 0 would turn an intermittent quality
problem into a hard 400 for exactly the models a GM is most likely to pick.
There is no key here to verify against, and #339 was already this mistake once:
a documented request field, shipped without putting it against the real
endpoint, silently doing nothing while a unit test agreed with the same wrong
belief. Belongs with v4.2.0's capability flags. Both paths carry a comment
saying so, and a test pins that they stay bare.

Ollama gets the change on the same reasoning as llama.cpp but was not verified —
no instance was reachable. Its `options` dict is already the proven envelope
this function uses for `num_ctx`, so it is not a guess at the shape, and a test
covers that setting one does not clobber the other. Flagged in the comment
alongside the open `format: <schema>` question.

1,202 passing before, 1,207 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`python -m evals --repeat N` runs every fixture N times and prints min/mean/max
and spread per metric. Every run is recorded, not just the last: the spread is
the finding, so a history holding one of N runs would hide the thing the repeat
was for. Thresholds still check the final run — a pass/fail on an averaged score
would let a catastrophic run hide behind good ones.

## Correcting the previous commit

`0002fa4` said "beat validation is now identical across all three runs". That
was true of that sample and I let it imply determinism. It is not.

Re-running with `--repeat 3` gave beat validation 0.750-0.846, and isolating
extraction from compose settled it: three identical beat-extraction calls on the
real 77-minute transcript at temperature 0 returned 26, 26 and 25 beats, two
byte-identical and one not.

The earlier evidence looked stronger than it was because the determinism probe
used a five-line toy prompt, where five identical requests did return five
identical bodies. At real scale it does not hold. That is the same mistake this
milestone keeps finding — a check that agrees with the belief that produced it —
and it is worth naming rather than quietly restating the numbers.

The likely residual cause is the server's continuous batching (`--parallel 2`):
batch composition changes the order of floating-point reductions, so identical
greedy requests can diverge. That is a hypothesis, not a measurement; it is not
a sampling parameter and no code change here would address it.

## What greedy decoding did do, measured over six runs

    metric              before (n=3)        after (n=6)
    coverage            0.091 - 0.727       0.545 - 0.727
    attribution         0.000 - 0.667       0.333 - 0.571
    chronology          n/a - 1.000         0.810 - 1.000
    beat validation     0.240 - 0.824       0.750 - 0.846

Coverage spread falls 0.636 to 0.182 and its floor rises from 1 of 11 events to
6 of 11. Beat validation spread falls 0.584 to 0.096. Chronology is now always
measurable, where before one run covered too few events to have an order at all.
The catastrophic draw is gone; the wobble is not.

So the headline is smaller than "fixed" and larger than "no change": the failure
mode where a GM silently gets a near-empty summary has been removed, and a real
band remains. #423 stays open for it.

1,207 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage was only ever measured on the finished prose, so a missing event told
you it was missing and nothing about where it went. There are two stages that
can lose it and they need opposite fixes: an event that was never extracted
cannot be recovered by tuning compose, and one that was extracted, validated and
then left out of the prose will not be recovered by extracting harder.

`coverage_in_beats` scores the same ground-truth events against the verified
beats, before compose runs. Free — `score_coverage` is pure code and the
rendering already exists — so it is always measured rather than hidden behind a
flag, and it appears in both the per-run table and the `--repeat` variance
table.

Measured on the real session, three runs, per event:

    coverage in verified beats   0.727  0.818  0.636   (mean 0.727)
    coverage in composed prose   0.455  0.636  0.545   (mean 0.545)

    never extracted             1/11
    lost or flaky at compose    3/11
    survived both stages        7/11

So compose discards roughly a quarter of what extraction successfully found and
the validator confirmed. One event — Wyatt finding the corridor of amphora — was
extracted and verified in all three runs and dropped from the prose in all
three. That is not sampling noise; it is a consistent editorial choice.

Which settles the question that prompted this measurement. Making compose greedy
would have made that drop *reliable* rather than fixed it, so the compose problem
is not a sampling problem: it is that compose may silently discard an event the
validator has already confirmed happened. Worth noting the fixture weights all
eleven events equally while a summary legitimately compresses — but the
human-approved reference summary mentions all three dropped events, so the
reference disagrees with compose in every case.

1,207 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compose was the last step in the pipeline that was trusted rather than checked.
Measured, it discarded about a quarter of the events extraction had found and
the validator had confirmed — one of them in three runs out of three, so a
consistent editorial choice rather than sampling noise.

Three changes, and I want to be exact about which of them is demonstrated to
work, because one of them is not.

## 1. The prompt was asking for it

`COMPOSE_SYSTEM_PROMPT` said "cover the key events", inviting a selection pass
over a list that has already been filtered twice: extracted as a beat, then
checked against the transcript. There is no third sieve to apply. It now says
every event in the list must be represented, while allowing related events to
share a sentence and minor ones a clause.

## 2. The harness was measuring a copy of the pipeline, not the pipeline

The first version of this fix measured as doing nothing at all. It was not: the
#349 harness re-implements the compose *sequence*, so it never ran the new code.
A harness built to measure changes to the pipeline could not see a change to the
pipeline.

`recover_dropped_events` now takes its compose call as an argument, and the
harness calls that same function instead of mirroring it. Anything the pipeline
does to a summary after composing has to be reachable from there or the eval
quietly stops measuring the product. This is the most valuable part of the
commit and it was found by the measurement disagreeing with the code.

## 3. The detector does not fire on real sessions

`unrepresented_beats` compares a summary against the beats it was built from and
`recover_dropped_events` asks once more for anything missing, keeping the second
draft only if it covers more. The machinery is right and unit-tested: given a
summary that drops an event, it re-requests it by name and keeps the better
draft; given one that recovers nothing, it keeps the first.

It does not work on real data. Across six live runs on a real session it flagged
nothing, while the harness measured 1-2 verified events per run missing from the
prose. Two detectors were tried: any two content words (too weak — a session's
beats share heavily specific vocabulary, so a dropped beat's words appear anyway
because its neighbour got written up) and words unique within the session (too
strict — most beats have no word of their own, so they are skipped unchecked).

Shipping it anyway: it is non-fatal, costs nothing when it does not fire, and is
correct in the clear-cut cases the tests cover. But it is not the fix, and the
beats-to-prose gap is unchanged at ~0.15 on the real fixture. The honest next
step is a stronger signal than token overlap against a beat's own summary — the
eval harness only detects these drops because its fixtures carry hand-authored
per-event mention groups, which production does not have.

## Also

A test double was returning "The final summary." for a beat about disarming a
rune trap — a compose step that ignored its entire input, which is exactly the
condition being checked for now. Fixed the double rather than loosening the
assertion, as in #343: a test double that models an impossible response will
eventually assert that the impossible is fine.

1,207 passing before, 1,215 after. #423 stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extraction is not deterministic even at temperature 0, and the draws are not
nested: three identical calls on one real session returned 26, 26 and 25 beats,
and the 25 was not a subset of the 26. Different passes surface different
events. Since every beat is validated against the transcript by pure code
before anything uses it, taking the union across passes cannot smuggle in a
hallucination — the property that makes best-of-n dangerous for prose is exactly
the one the validator already removes here.

## Measured, three runs each

    metric              single pass          best of two
    coverage (prose)    0.545 (0.455-0.636)  0.667 (0.545-0.818)
    coverage in beats   0.697 (0.636-0.727)  0.818 (0.636-1.000)
    attribution         0.510 (0.429-0.600)  0.542 (0.500-0.571)
    chronology (tau)    0.794 (0.714-0.867)  0.868 (0.833-0.905)
    beat validation     0.740 (0.625-0.846)  0.812 (0.714-0.923)

Every metric improved. Coverage is up 22% relative, and one run reached 1.000 in
beats — all eleven hand-verified events found — where single-pass never exceeded
0.727. The ceiling moved, which is the thing the last two attempts could not do.

Two honest caveats. Ranges overlap at n=3, so this is a real signal rather than
a tight one. And it did **not** reduce variance the way I expected: spread went
up, not down (coverage 0.182 to 0.273). Averaging more draws makes the mean
better and evidently not the spread, at least at this sample size.

The beats-to-prose gap is unchanged at ~0.15. Best-of-n raises what compose is
handed; compose still discards its usual share. The two problems are
independent, as the stage measurement said they were.

## How the union is merged

`dedupe_beats` decides two beats are one event from their **cited evidence**,
not from how alike the summaries read. Two passes word the same event
differently, so text similarity would be a guess; citing the same transcript
line is a fact, and keeping the merge grounded in the transcript is the
principle the validator already runs on. Actors must overlap too — a single
line can carry two people's actions, so shared evidence alone would merge
events that only happened at the same moment.

Biased towards keeping. A duplicate costs a sentence written twice, which a
reader notices and shrugs at; an over-merge silently loses an event, which is
the failure this milestone exists to stop. There is a test that dedupe is a
no-op on a single pass, because an over-eager merge there would drop events for
every install that never runs a second one.

Validate first, then dedupe: the other order would let a beat that cannot pass
validation displace the pass that got the same event right.

## And the drift, fixed at the cause

The window/extraction loop existed twice — once in the pipeline, once in the
eval harness. That duplication is why a compose fix measured as doing nothing
an hour ago: the harness never ran it. `gather_beats_over_windows` is now the
one implementation, with `extract` injected, and the harness calls it. Second
time this pattern has bitten, so this fixes the cause rather than the instance.

## Cost

This doubles the most expensive call in the pipeline — time on a self-hosted
box, money on a metered provider. `BEAT_EXTRACTION_PASSES` is a module constant;
whether hosted campaigns should be able to turn it down is a product decision,
not one to bury in a default.

1,215 passing before, 1,222 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An attempt at raising the floor that did not raise the floor. Recording it with
its numbers rather than quietly reverting, because the reason it failed is worth
more than the change.

Fixed two passes raised the mean and left the worst run alone, which makes
sense: the worst run is the one where both draws were poor, and no fixed count
rescues it. So passes now continue while they are still paying and stop on the
first that adds nothing new, bounded 2 to 4. A session whose passes agree costs
what it cost before; only sessions going badly buy more draws.

## It did not work, and the premise is why

    metric              fixed best-of-2 (n=3)   adaptive (n=4)
    coverage            0.667  floor 0.545      0.568  floor 0.455
    coverage in beats   0.818  floor 0.636      0.750  floor 0.636

The coverage-in-beats floor is *identical*. Not improved, not noisily similar —
the same number.

**Agreement between passes is not evidence of completeness, and the stopping
rule assumed it was.** A bad run is not one where passes keep turning up new
events; it is one where the model consistently misses the same events. Pass two
agrees with pass one's poor view, adds nothing, the loop stops — precisely when
continuing would have helped. The extra passes never fire on the runs that need
them, which is why the floor cannot move.

The mean also came out lower than fixed-2, though at n=3 and n=4 with heavily
overlapping ranges I would not read that as a real regression. The floor number
is the one that means something, and it did not move.

## Why it ships anyway

With MIN=2 this is fixed-2 plus passes that only run when they are productive,
so it cannot extract less than before. It is more code for no measured gain,
which I would normally revert — but it does strictly more work exactly when
there is more to find, and the failure is in what the stopping rule *proves*,
not in what the loop does.

Validation happens per pass before merging, so "found something new" means
something new that survives checking. Otherwise a model inventing fresh events
every pass would look like progress and run to the cap for nothing. There is a
test for that, and one that a session whose passes agree still costs the
minimum.

A test of my own caught something about the deduper on the way: the first
version gave every synthetic "distinct" event the same timestamp and actor, and
the loop stopped at two. That was correct — those are one event by the merge
rule, since they cite the same line. The fixture was wrong, not the code.

## What to try next

The floor run finds 7 of 11 events. Extraction currently gets the whole
77-minute session as a single window, because window size is derived from the
provider's context and this model has 131k of it. That is a lot of transcript to
ask for uniform attention across, and #340/#341 optimised for fitting *more* in
— which may be the wrong direction for extraction specifically. Smaller windows
are testable today with `--context-tokens`, no code change needed.

1,222 passing before, 1,225 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extraction window size followed the provider's declared context, so a 131k model
was handed an entire 77-minute session — 21,346 tokens of transcript — in one
window and asked to find every event in it. Cutting the window up raised the
floor, which nothing else tried in #423 had managed: sampling temperature killed
the catastrophic draw but not the floor, fixed best-of-2 raised the mean and left
the floor alone, and the adaptive pass loop left it identical.

    windows   coverage floor   coverage mean
        1            0.455          0.568
        4            0.636          0.727
        5            0.545          0.659

It costs no extra tokens. The same transcript is read either way, in more and
smaller pieces. #340 and #341 were right to fit more into a window for *cost*;
this says the opposite holds for extraction *quality*, and they do not conflict
because they are about different calls. Only extraction is capped — compose gets
a beat list rather than a transcript and wants room to write across the session.

## Correcting yesterday's analysis

I reported that capping extraction alone "did not cleanly confirm" the result and
blamed a possible compose effect. That was wrong, and the arithmetic says so
without spending a GPU cycle on it.

The two runs I was comparing did not differ in what I thought. `extraction_windows`
applies `prompt_budget_tokens`'s 0.7 fraction; the old harness maths did not. So
the "clean" run had a 4,881-token budget and 5 windows where the run it was meant
to reproduce had 7,339 and 4. I changed the window count in the act of removing
what I believed was a confound, and then read the difference as evidence about
compose.

The other two candidates were dead all along, and code inspection settles both:
`context_tokens` reaches only Ollama's `num_ctx`, never the llama.cpp path, and
the harness compose has no `fits_in_context` check. Window count was the only
live variable in the experiment.

Worth naming because it is the same failure the milestone keeps finding — a
measurement that agrees with the belief that produced it. The fix was to compute
what each configuration actually did rather than to run it again.

## The cap is a measured value, not a derived one

8192 was the first thing tried that was not "the whole session", and 5 windows
already looks past the optimum. `QB_EXTRACTION_CONTEXT_CAP` makes it tunable,
because the useful window will move with the model and because smaller is
demonstrably not always better: windows carry a fixed number of overlap *lines*,
so past some point an event spanning a boundary is lost rather than found, and
the per-window header and legend are paid again on every window.

A sweep for the actual optimum follows. Until then this ships at 8192, which is
worse than 4 windows and much better than 1.

1,225 passing before, 1,227 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swept extraction window size on the real session, four runs per point, against
qwen3.5-9B:

    windows  coverage floor  coverage mean  attribution  beat validation
       1          0.455          0.568         0.533          0.747
       2          0.545          0.614         0.810          0.729
       3          0.455          0.523         0.425          0.736
       4          0.727          0.818         0.804          0.795
       7          0.727          0.773         0.615          0.723

Four windows — a ~7,000-token budget, cap 11264 — is best or tied-best on every
metric, so that is the default. The coverage floor goes from 5 of 11 events to
8 of 11 worst-case, against 5 of 11 when the whole session went in one window.

## The tail says something I did not predict

Going finer than four does not cost coverage: seven windows holds the same
floor, 0.727. What it costs is **attribution** — 0.804 down to 0.615 — and
validation rate, 0.795 to 0.723.

That is not the boundary-loss effect I predicted before running this. The events
are still found; they are attributed worse, because each window carries less
surrounding dialogue for the model to ground "who did this" in and to cite from.
Window size trades finding events against attributing them, and those two are
what this milestone is actually about, so the balance point matters more than
either alone.

The dip at three windows is unexplained. It is worse than both its neighbours on
coverage and attribution, which no smooth story predicts; most likely it is where
the boundaries happen to fall in this one transcript, and four runs cannot
separate that from noise. Recorded rather than smoothed over — it does not move
the optimum.

## Where #423 stands

Coverage floor across the whole issue: 0.091 when it was filed, 0.455 after
greedy decoding removed the catastrophic draw, 0.727 now. The thing that
mattered was not how many times to ask, which is what the first three attempts
all tried, but how much to ask about at once.

`QB_EXTRACTION_CONTEXT_CAP` keeps it tunable. This is one model on one session,
and the useful size will move with the model — the point of #349 is that the
next person can measure that rather than argue about it.

1,227 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`drop_silent_tracks` runs on every session and drops any track whose audio never
rises above the floor. It decided that by probing 32 fixed positions, reading at
most one second at each — while the *spacing* between probes scaled with file
length. On a 77-minute session that is 32 seconds inspected out of 4,639, with
145-second gaps, so the guard only ever guaranteed finding a speaker whose audio
held one contiguous block longer than that gap.

A quiet player does not speak in one block. They say a few words every several
minutes, which is exactly the distribution that falls between probes — and the
consequence is silent: the track is dropped before transcription, the player
vanishes from the transcript, and the pipeline reports success.

Measured against the old implementation, whether someone survived came down to
whether their speech happened to line up with the probe positions:

    40 utterances x 4s (163s of speech)   kept
    20 utterances x 4s  (80s of speech)   DROPPED
    10 utterances x 3s  (30s of speech)   DROPPED
     5 utterances x 2s  (10s of speech)   kept
     3 utterances x 2s   (6s of speech)   kept
     8 utterances x 1s   (8s of speech)   DROPPED
     one 0.5s word in an hour             DROPPED

Eighty seconds of speech deleted while six seconds survives. Not a threshold
anyone chose — an alignment artefact between two evenly-spaced sequences.

`has_audible_speech` reads sequentially and stops at the first sample loud
enough. Exact, no sampling, and *faster* in the case that matters: anyone who
speaks ends the loop as soon as they do, and only a genuinely silent track is
read to the end — which is the one file worth being certain about.

## The old test could not have caught this

`test_one_brief_utterance_is_enough_to_keep_a_track` cites this exact incident
in its docstring — "Sol_Invictus spoke for 163 seconds out of 4639" — and then
builds a 60-second file. At that length the probes are 1.9s apart and 1s wide,
so coverage is 53% and the assertion cannot fail whatever the code does. Green,
and validating nothing.

I nearly repeated it. My first replacement used 40 utterances of 4 seconds at
real session length, which *looks* like the harder case — and the old code keeps
it, because evenly-spaced speech resonates with evenly-spaced probes. I checked
by rebuilding the old implementation and running the new fixtures against it,
rather than assuming a longer file was automatically a better test. The shapes
that ship are ones measured to fail before the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#397 stopped a second recording destroying a GM's edited transcript by refusing
the upload with a 409. That guard lives in the bot-upload router — and there are
four ways into `process_audio`:

    app/routers/bot.py         guarded
    app/routers/sessions.py    not guarded   retry, deliberately
    app/routers/admin.py       not guarded   reprocess, deliberately
    app/tasks/reminder_tasks   not guarded   erasure regenerate, NOT deliberately

`run_member_erasure(regenerate_summaries=True)` queues the task directly, and
the task unconditionally wrote `session.transcript` and `session.summary`. So
erasing one member's recordings silently destroyed a *different* GM's edited
summary. Same loss the issue was filed for, through a door the guard did not
cover.

The check now sits with the write it protects. `process_audio` refuses when
`transcript_updated_at` is set unless the caller passes `replace_existing`, and
the default is False — so the safe behaviour is what you get by omission, and
replacing is the thing that has to be spelled out. Retry and admin reprocess say
so; erasure does not, and is protected by saying nothing.

Guarding one router left the invariant enforced by discipline across four call
sites, which is precisely how it reached a second entry point. Bot upload still
does its 409 first, because a caller deserves an error rather than a silently
dropped task, and then passes `data.force` through — the task re-checks rather
than trusting it, since three other callers exist.

Tests: erasure's call site queues five positional arguments, never asking to
replace; and the signature keeps `replace_existing` defaulting to False, so a
future caller cannot get overwrite behaviour by accident.

1,227 passing before, 1,233 after with #425.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`transcribe_session` — the default, non-VAD path — had zero direct tests. #342's
last acceptance criterion asks for coverage of a server echoing a diarization
label and of results returned out of order, and neither existed.

Six tests, each pinning a production failure rather than restating an assertion:
an invented `SPEAKER_00` raises rather than entering the transcript as a ghost
identity; out-of-order segments are attributed by label lookup rather than array
position; a full identity swap fails loudly; the `speakers` form field is built
from track labels so a well-behaved server has something correct to echo; and
blank segments are filtered without silence becoming a phantom line.

## A gap the tests surfaced and do not close

`transcribe_session` routes each returned segment back to a track with
`by_label.get(seg["speaker"])` — so while the *emitted* label is always
`track.label`, **which** track a segment lands on still depends on the server's
echoed name. A total swap is caught, because the victim track ends up with no
segments and trips `check_every_speaker_was_transcribed`. A *partial* swap is
not: one utterance mislabelled with another real, sent speaker's name is
silently attributed to them, and nothing downstream can tell.

That is architecturally unlike `transcribe_track`, which never reads the echoed
field at all. It is not a regression and not something a test can fix, so it is
recorded here and filed rather than patched under a test-only change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When both `guild.get_member` and `bot.get_user` miss, `display_name` stayed as
the raw user id and that snowflake was written into `speakers.json` — becoming a
transcript speaker label a GM then reads as `481912...`.

The backend's fallback did not save it: `speakers.get(stem) or ...` only catches
a *missing key*, and a snowflake present as a truthy value passes straight
through.

Both bot-side sites now use one shared `unknown_speaker_label()`, producing
`Unknown speaker <last4>` — deliberately the same shape the backend already uses
for a missing key, so the two sides never disagree about what a gap looks like.
`recording_status.py` had the identical bare fallback for the live dashboard and
gets the same treatment.

**The session-flagging half of #344 is not done, and is blocked rather than
skipped.** `AudioUploadRequest` has `uncaptured_member_ids`, but that means
"present and silent" — a different thing from "captured, name unresolved", and
`attendance_unmatched_speakers` matches on Discord-id-to-member linkage, not on
name resolution. Surfacing this needs an additive field on the bot API and a
place to show it, which is a backend change and plausibly a contract-version
question. No speculative plumbing was added for it.

208 passing before, 211 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Beats were computed inside `process_audio` and discarded the moment the prose
was written, so the summary was the only artifact of a run. Three acceptance
criteria across three issues all depended on them outliving the task, and none
could be met without a table:

* **#332** — "beats persisted, not discarded after summary is written". The
  timeline UI, highlight input, lore extraction and `/ask` index that issue names
  cannot be built on a value that dies inside one task.
* **#333** — "validation results recorded per session so accuracy can be tracked
  over time". There was nothing to track; the #349 harness measures fixtures on
  demand, not production.
* **#329** — the resolved speaker legend, likewise computed and thrown away, so
  a reprocess had to recompute it and hope it matched.

## Two tables, because runs are plural

`summarisation_runs` holds one row per summarisation, carrying what the run was
*given* — the legend, the model — as well as what it produced: beats extracted,
beats valid, and how many extraction passes the adaptive loop needed. A run that
took four passes is a run where the model was struggling, which is worth being
able to see.

`session_beats` holds one row per extracted event, with the validator's verdict
in `problems`. Empty means it passed. That column is what turns "flagged rather
than dropped" (#333) into something a person can look at rather than a line in a
worker log.

Runs are **appended, never replaced**. A session can be summarised more than
once, and #423 established two runs over the same audio genuinely differ — so
overwriting would destroy the record of what an earlier summary was built from,
at exactly the moment a GM is reprocessing in order to compare.

The legend is stored **as it was used**, not recomputed later: a member's active
character can change (#330), so re-deriving it for an old run would describe that
session with names nobody used at the time. Evidence is stored as seconds rather
than rendered stamps, so tracing a beat to its lines does not depend on the
transcript format — which #345 changed once already.

## Shape of the change

`SummarisationRecord` is a mutable record the pipeline fills in, passed down
rather than returned up, so `summarise`'s two dozen call sites are untouched and
a caller that does not care simply omits it. Same injection shape as the compose
and extract seams, for the same reason.

Persisting is best-effort and logged on failure. A provenance write must never
cost a summary — that would trade a small gap for a large one.

Not backfilled. Beats for past sessions were never captured and a plausible
re-derivation would produce rows that look authoritative and are not — the same
call #335 made, for the same reason.

Migration `d5e6f7a8b0c1`, verified against a real Postgres: applies on the full
chain, downgrades, and re-applies cleanly.

1,233 passing before, 1,246 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rows landed in 2791c24; this is the read side, so #424 becomes purely a
frontend job rather than a frontend job plus a backend one.

    GET /api/sessions/{id}/summarisation-runs          every run, newest first
    GET /api/sessions/{id}/summarisation-runs/latest   the one behind this summary

GM-only. A failed beat carries the reason it failed, and those reasons quote
transcript content — including lines another member may since have had erased —
so this is not player-visible.

## Citations are resolved, and failures to resolve are shown

Beats store cited timestamps as seconds; a GM needs the line. Resolution goes
through `beat_service.build_transcript_index`, the same function validation uses,
so there is one notion of "what line is at this second" rather than two that can
drift.

A stamp that no longer resolves still comes back, with null text. A GM edit can
remove a cited line and erasure redacts spoken text while keeping the timeline —
in both cases "this beat cited here, and here is now empty" is information.
Dropping the entry would silently shorten the evidence list and make a beat look
less supported than it was when the validator confirmed it.

`problems` is carried through verbatim rather than reduced to a boolean, because
"'Kira' did not speak any cited line" tells a GM something actionable and
`verified: false` does not.

A session with no recorded run reads as empty rather than erroring, and the
endpoint says why: sessions summarised before this existed have no rows and none
can be reconstructed. Empty means "no record", never "no events".

## One thing a test caught

Ordering runs newest-first was broken in a way production would have hidden.
`created_at` defaulted to `now()`, which in Postgres is *transaction-start* time
— so two runs written in one transaction tie, and "newest first" stops meaning
anything. In production each run is its own Celery task, so the tie never
occurs and the fragility would have sat there until something depended on it.
Both model and migration now use `clock_timestamp()`, verified against a real
database to be what the column actually gets.

1,246 passing before, 1,257 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`chunk_text` cut the transcript by character count with a preference for
paragraph and sentence breaks, which for a transcript means a chunk can begin
mid-utterance — handing the extractor half a sentence with no speaker attached —
or end mid-utterance, truncating a statement so the extractor reads a claim
nobody made. Attribution is the thing this milestone is about, and lore
extraction was reading text cut in a way that destroys it.

Since #335 the structure to avoid that exists. `chunk_transcript_for_extraction`
packs whole `TranscriptSegment` rows up to the budget, so a boundary can only
fall *between* segments. A segment longer than the whole budget becomes its own
chunk rather than being split or dropped.

## The fallback is the important part

The #335 migration deliberately did not backfill, so **every session recorded
before it has no segment rows**. Without a fallback this would have silently
broken lore extraction for the entire existing corpus. Sessions with no rows go
through `chunk_text` exactly as before, and the test that proves it asserts
equality against `chunk_text`'s own output rather than merely that chunks come
back — the weaker assertion would pass even if the fallback produced something
different.

`chunk_text` itself is untouched, since callers that only have a string still
need it.

The summary is kept as its own leading chunk rather than merged into the packed
segments: it has no segment boundaries of its own to respect, and prepending it
to the first chunk would push a real utterance out of it.

1,257 passing before, 1,264 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#334 is titled "compose the summary from validated beats only, **with a critic
pass**". The critic was never built — grep for "critic" returned nothing.
`recover_dropped_events` (from #423) is not it: that checks verified beats
*missing* from the prose. This checks prose *unsupported by* the beats. Omission
and hallucination are complements, and only the first had a check.

Until now the only defence against an invented person was a line in the prompt
telling the model not to invent one. That is trust, which is the thing this
milestone exists to replace.

## Why the actor direction works where content overlap did not

#423 tried a content-overlap detector for the omission direction and it failed
twice — a session's beats share heavily specific vocabulary, so token overlap
cannot separate "covered" from "adjacent". The actor direction is tractable for
a reason that does not apply there: **the set of legitimate names is closed.**

A name clears the check if it is a speaker label, display name, character, or an
actor in a verified beat — or if it appears in a verified beat's own summary
text, which is how a GM-narrated NPC gets through, since the validator accepts
such NPCs into a beat's prose without ever listing them in `actors`. That last
clause is the difference between a usable tripwire and one that fires on every
NPC a GM ever described.

## Log-only, and it should stay that way

The delegate that built it was asked for its honest read and gave one: a coarse,
English-prose-shaped membership test with real blind spots in both directions,
fine as a cheap tripwire, not accurate enough to gate output or to show a GM as
if it were a finding. I agree, and the wiring reflects it — never raises, never
rewrites, return value untouched. A summary a GM can read beats one withheld
because a heuristic was unsure.

The count lands on `SummarisationRecord`, so it can be tracked like the other run
metrics rather than only appearing in logs.

## The reason, never the sentence

The delegate flagged that logging flagged sentences verbatim contradicts this
module's own docstring — "neither the transcript nor the summary is ever written
to logs". It was right, and it asked rather than assuming. For a session of real
people talking, a summary fragment is exactly what that invariant keeps out of
log aggregators and support tickets. Only the reason is logged now, which names
the ungrounded name anyway; anyone who needs the sentence has the summary.

The test asserting the old behaviour would have gone quietly vacuous under that
change, so it is now an explicit assertion that the name appears and the
sentence does not.

1,264 passing before, 1,273 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`num_ctx` *was* implemented, and the test proving it works has been green since
ccc29d6. The defect is that the whole protection sat behind `if context_tokens:`
— and of the eighteen `generate_structured_text` call sites, exactly one passes
a window. So in practice almost every Ollama request in the product fell back to
Ollama's 2048-4096 default and was silently truncated, which is the bug #337 was
filed for, still live in a file that looks like it fixes it.

## Why the default is a window and not an absence

The obvious fix is to thread `context_tokens` through the other seventeen call
sites. That fixes seventeen call sites and leaves the eighteenth, written next
month, unprotected again — with no test able to notice, because "nobody passed a
window" is indistinguishable from "this call doesn't need one".

So `None` now resolves to a conservative 32,768 rather than switching the
budgeting off. A caller that knows better still passes the real number, and #336
is that plumbing — but it becomes an optimisation rather than the thing standing
between a self-hoster and a summary of 3% of their session.

This deviates from what #337 proposed ("sourced from the provider's declared
window"), and the issue has been updated to say so rather than left to disagree
with the code.

## Why 32,768 is not a new memory risk on Ollama

It is the same value `LLMConfig` already defaults to, so the *largest* prompt in
the product — `summarise` via `get_llm_config` — has been sending `num_ctx:
32768` all along. Applying it to the smaller calls cannot raise the allocation
ceiling above one already in production. Under-declaring costs extra chunking;
over-declaring costs silent truncation, so the number stays conservative.

## Also here

`preflight_prompt` is the input-side counterpart to `_warn_if_prompt_truncated`
and runs on every provider path, structured and prose. It warns rather than
raises — see #336 for why routing to a chunked path is not available from the
transport layer, and why an estimate should not refuse a prompt that would have
worked. It counts the system prompt, which shares the window; a preflight that
ignores it is the accounting error that drops the speaker legend.

The three independent `32_768` literals — here, `settings_service`, and
`audio_service` — are now one constant and two aliases. They had to agree and
nothing made them.

A latent crash went with it: `if context_tokens:` let a non-numeric setting
through to `int()`, so a typo'd context window raised `ValueError` from the
transport layer instead of falling back.

## Verified against the old behaviour

All ten new tests were run against the pre-change code and fail there —
`KeyError: 'options'` on the body, not merely an assertion mismatch. The two
that pass on both sides are guards against regressing the explicit-window path,
and are labelled as such.

1,273 passing before, 1,284 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LLMConfig.context_tokens` has been resolved correctly since ccc29d6 and then
gone almost nowhere. Of eighteen `generate_structured_text` call sites, one
passed it — highlights, the whole lore pipeline, rephrase/promote/statblock/
convert_stats, name generation, session titles, journal entries and the GM
Workbench tool runner all sent prompts against a window they never declared.

c54d9af made that survivable rather than silent: an undeclared window now
resolves to a conservative 32,768 instead of switching budgeting off. This is
the other half — the accuracy half. A 200k-window Anthropic model should not be
budgeted as 32k, and an 8k llama.cpp slot should be told the truth rather than
flattered by four times its real size.

`context_tokens` is threaded through the thirteen domain functions in
`audio_service` and passed from `llm_cfg` at every caller. Keyword-only and
defaulting to None throughout, so the fallback stays the behaviour for anything
not yet converted.

## Why the transport layer preflights but does not chunk

#336's acceptance criteria ask for over-budget prompts to "route to the chunked
path". The transport layer cannot do that — it has no idea how to split a
lore-merge or statblock prompt, and only callers that own a chunked path
(`summarise`, `extraction_windows`) can. So `preflight_prompt` warns loudly and
sends, and the criterion is amended on the issue rather than left contradicting
the code.

It warns rather than raises for a second reason: `estimate_tokens` is characters
over a constant and deliberately pessimistic, so a raise would refuse prompts
that would have worked. Refusing to summarise a session beats summarising a
tenth of one, but it does not beat summarising all of it. `_warn_if_prompt_
truncated` reached the same conclusion from the other direction. Both become
hard checks when a provider can declare a real tokenizer (#351).

## Test doubles that were hiding behind an attribute

Around twenty test files build a fake `LLMConfig` as a `SimpleNamespace` or an
ad-hoc `type(...)`, none of which carried `context_tokens`. Reading it raised
`AttributeError` inside the task bodies, which those tasks catch — so the
symptom was not an error but a *result recorded as `failed`*, in tests asserting
`ready`. Fifty-five of them. They are all now given a window, but the shape of
that failure is worth remembering: a hand-rolled double drifts from the dataclass
it stands in for, and the drift surfaces as a wrong status rather than a crash.

/ask is not in scope here — it is #426, filed separately against v4.2.0 after
this pass found it. `beat_service` already passed a window and is untouched.

1,284 passing before, 1,286 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most of #338 landed already: `_PROSE_MAX_TOKENS` replaced the 1024 caps,
`enable_thinking` reached the llama.cpp prose body, and `_reject_if_truncated`
covers three of the four prose providers. What was left is the part the issue's
last criterion asked for and the code contradicted.

## The decision, and why it is now written down

#338 asked for a test that a truncated prose response *raises*. The code does
the opposite and has since #293. Ryan's call, recorded on the issue: return-but-
flag is right — half a summary a GM can read and correct beats no summary at
all, which is what raising would produce. Structured output keeps raising,
because a partial JSON object cannot be trusted or repaired, whereas partial
prose is merely short.

That asymmetry now has its rationale in `_reject_if_truncated` itself, including
the warning not to "fix" either half to match the other. It was previously a
one-line aside, which is how a later reader talks themselves into making them
consistent and loses either GMs' half-summaries or #293's guard.

## Returning it is only defensible if someone is told

The flag was the missing half. A truncated summary was returned with nothing
marking it beyond a line in a worker log — so a summary that stopped
mid-sentence was indistinguishable from one that ended there, and a GM's
reasonable reading of the second is "the model missed the last hour of our
session". `_reject_if_truncated` now returns whether it fired, `_dispatch_prose`
threads a `SummarisationRecord` down to every provider, and the flag lands on
`summarisation_runs.truncated` where #424's API already exposes it.

Any prose call in a run sets it — single-shot, a window note, the reduce step,
the compose step. Deliberately not per-call: a GM asking "is what I am reading
cut short" does not care which call it happened in, and the map step is the
easiest to lose because its output is consumed rather than shown.

`_persist_summarisation_run` no longer returns early for a run with no beats
*when it was truncated*. The general rule stands — an empty row otherwise reads
as "this run found no events" — but a prose-fallback run that was clipped has
nothing else to report and is exactly the thing worth reporting.

## A provider that checked nothing

`_summarise_ollama` never called `_reject_if_truncated` at all. The other three
prose providers rejected or flagged a response cut off at the output cap and
Ollama simply returned it — on a self-hosted-first product, the backend most
likely to be doing it. It also sent no output cap, so it took whatever
`num_predict` default the operator's Ollama version happens to have. Both fixed,
with `options` merged rather than assigned so `num_ctx` and `num_predict`
coexist — the same mistake `test_ollama_greedy_decoding_does_not_clobber_num_ctx`
already pins down on the structured path.

## Tests that can actually fail

The persistence tests build a `SummarisationRecord` by hand, so they would pass
unchanged if nothing ever *set* the flag. Three more in `test_summarise_chunking`
patch the HTTP layer and drive a real `summarise()`. Verified by mutation:
neutering the record threading inside `_dispatch_prose` fails both truncation
tests and leaves the negative control passing.

Migration `e6f7a8b0c1d2`, applied and rolled back against a real database — the
fifth pending on this branch, and the chain applies cleanly from empty.

/ask remains untouched: `_qa_*` is #426.

1,286 passing before, 1,296 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by verifying #425's acceptance criteria rather than by a test. The
sampling half of that issue is genuinely fixed — `has_audible_speech` scans
exhaustively, and the three realistic-length tests do fail against the old
implementation, which I checked by restoring it rather than trusting the
docstrings. Two criteria were not met at all:

  - A dropped track reaches the GM through the same uncaptured_member_ids /
    attendance surface the bot-side path uses, rather than a log line
  - #348's fourth criterion holds for backend-dropped tracks too

`drop_silent_tracks` returned its silent list into a `logger.info` and nothing
else. `uncaptured_member_ids` was built solely from `presence.json`, which
carries only the people the *bot* declined to upload.

So the backend guard was half a guard. It stopped Whisper hallucinating phantom
speech under a quiet person's name — the thing #348 was about — and then let
that person vanish from the transcript with no more trace than a worker log,
which is the thing #425 was about. The GM sees a summary written without them
and a pipeline reporting success.

## The gap is where the guard is most needed

`drop_silent_tracks` exists specifically for recordings the current bot did not
produce: an older session, a hand-placed directory, a bot one version behind.
Those are exactly the cases with no bot-side report to fall back on, so the only
route to the GM was the one that was missing.

Merged rather than assigned, mirroring the bot's own merge in `cogs/recording.py`
— the two paths reach the same state differently and either can find someone the
other did not.

## On testing it

Extracted as `merge_uncaptured_members` rather than left inline in
`process_audio`, which has no test harness and would have needed a large one
built to assert four lines. The helper carries the reasoning next to
`drop_silent_tracks`, whose output it consumes.

Also recorded while verifying: #348's first criterion — "`_peak_amplitude` scans
the full file, not the first 0.5 s" — is unmet *as worded*. It still samples five
windows. But it is no longer the drop decision: that now uses
`speech_bytes_written()`, an exact running counter, and `_peak_amplitude` survives
only as a log diagnostic. The letter is unmet and the intent is superseded by
something stronger, which is worth saying out loud rather than ticking the box.

1,296 passing before, 1,300 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither was caught by a test, and one could not have been.

## transcribe_track's fix has no regression coverage (#342)

`transcribe_track` ignores the ASR server's echoed `speaker` and stamps
`track.label` instead — that is the fix #342 asked for, and it is the *default*
production path, because VAD is on by default and every VAD span routes through
it.

Reverting that line to `seg.get("speaker") or track.label` — the exact defect
#342's body names as its second site — passes all 1,300 tests. Verified by
mutation, not inferred. Every VAD test patches `transcribe_track` out, and the
doubles return a pre-baked `"speaker": track.label`, so the fixture supplies the
answer the assertion checks. The function whose entire claim is "never reads the
echo" was never once given an echo to ignore.

Two direct tests now, against the HTTP layer: a diarization label and another
real participant's name are both ignored, and a response with no `speaker` field
at all still attributes correctly.

## The snowflake guard was fixed at the producer only (#344)

2114f42 stopped the bot *writing* a bare Discord id as a speaker name, and its
own message notes that `speakers.get(stem) or default` catches only a missing
key. The backend consumer kept exactly that `or`
(`reminder_tasks.py`), so a snowflake **present as the value** is truthy and
walks straight through into the transcript — where the LLM reads it as a
person's name and merges it into a real player, which is #344's original
symptom.

That is a live path, not a leftover. Every `speakers.json` written before
2114f42 has that shape; CLAUDE.md states the per-session audio directory is
never deleted automatically; and reprocess is one of four documented routes back
into `process_audio`. An old recording reprocessed on a new backend reproduces
the bug in full.

`resolved_speaker_name` checks symmetrically against the stem and mirrors the
bot's `unknown_speaker_label`, so a track handled by either side reads the same
to a GM.

## Also

`summarise` was called without a context window from `select_canonical_name`
(`routers/sessions.py`) — the one summarise call site that is not
`process_audio`. With `window = 0` it skips the `fits_in_context` branch and
sends the whole transcript in one prompt, which is precisely what #331 exists to
prevent. #336 threaded the window through every `generate_structured_text`
caller and missed this one because it reaches the provider through `summarise`.
I said on #331 that every call site now passes the real window; that was wrong,
and the issue has been corrected.

Three more `SimpleNamespace` LLM doubles needed `context_tokens`. Unlike the ~20
in ede1ac2, these surfaced as a 500 rather than a silently-`failed` status,
because a router has no task-level `except` to swallow it.

`get_vad_config`'s docstring claimed `enabled` "defaults to False" long after
#323 made it True — backwards for anyone sizing the blast radius of a change to
either transcription path.

1,300 passing before, 1,306 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five defects in the validator, found by verifying #333's acceptance criteria.
This is the issue whose stated purpose is that the accuracy floor "does not ride
on model size" because the check is pure code — so a check that does not run is
worse here than anywhere else in the pipeline.

## Check 3 was unreachable, and its test could not fail

`_coerce_beat` clamped `t_start` to the session length; `validate_beats` then
tested `beat.t_start > transcript_seconds`. The value had already been clamped to
that exact bound, so "starts after the session ends" could never be appended
under any input. `test_a_beat_starting_after_the_session_ends_is_flagged`
asserted only `t_start <= max(index)` — guaranteed by the clamp — and never
touched `result.ok`. Its own comment hedged: "the range problem may resolve".

Repairs now travel out of `_coerce_beat` and are recorded.

## "Consistent with the cited evidence" was never implemented

The third clause of check 3. Nothing compared a beat's range against its own
citations, so a beat claiming 00:05:00-00:06:00 while citing only 01:40:00
passed everything — real citation, right actor, sane range — and then sorted
into a place nothing supported, because chronology is `sorted()` on `t_start`.
The one field the guarantee depends on was the one field nothing checked.

**Re-anchored, not rejected**, and that is the substantive design call here. The
evidence has already been checked against the transcript; the declared range has
been checked by nothing. When they disagree the verified value wins. Rejecting
would turn a fixable ordering error into a missing event, which is the trade
this milestone exists to refuse — and it makes the chronology guarantee stronger
than it was, because the sort key is now derived from transcript-verified
timestamps rather than a field the model was trusted to fill in.

A hallucinated citation is never used as an anchor: that would trade a wrong time
the validator reported for a wrong time it invented.

`BeatValidation.repairs` carries corrections, separate from `problems` and not
affecting `ok` — a beat the code could fix did not fail. Persisted to
`session_beats.repairs` and exposed through #424's API, because a silent
correction is indistinguishable from a correct answer.

**The `scrambled_chronology` eval fixture was built to demonstrate exactly this
gap** — its notes say so, and its expected `chronology_tau: -1.0` encoded the
defect. It now scores +1.0 with coverage, attribution and validation all 1.0:
every event survives, correctly ordered. Fixture and notes updated to record the
fix rather than the gap; the five reversed timestamps still make it a
hand-checkable regression value in both directions.

## dedupe_beats preferred hallucinations

It kept whichever duplicate had more citations, with no reference to the verdict
— and validation runs *before* merging, so both sides already carried one. A
second-pass beat citing the same real line plus one invented stamp beat a clean
single-citation beat. Since `render_beats_for_compose` skips failing beats, the
event then vanished from the summary having been both correctly extracted and
correctly checked. Verified beats now win outright; citation count only breaks
ties between equals.

## The extraction loop's docstring described a filter that did not exist

"'Did this pass find anything new' has to mean anything new *that survives
checking*" sat three lines above `gained = len(merged) - len(validations)`, which
counted flagged beats. A pass of fresh hallucinations looked like progress and
ran to the cap — full re-extraction over every window, paid for nothing. The
test named for this passed because its invented beat was identical every pass, so
the deduper collapsed it; it now varies actor and stamp per pass, so only the
verdict can stop the loop, and it fails against the old code.

## Also (#332)

- `_persist_summarisation_run` flushed without a savepoint. Catching the
  exception does not clear a session needing rollback, so the *next* commit —
  transcript and summary — would die of PendingRollbackError. Losing the summary
  because provenance failed is exactly backwards. The test guarding this passed
  `identities=[object()]`, which raises before any DB call, so it could not
  catch it; there is now one that writes a NUL byte and asserts the session is
  still committable.
- `extract_beats` was never once invoked by the suite — every test patches it
  out and the doubles return pre-parsed dicts, so its parsing had no coverage.
- A response needing structural repair means the json_schema grammar was **not**
  enforced, which #332 asks be flagged as degraded and which #281 showed can
  happen silently while every unit test stays green. Recorded on the run.
- `used_beats` and `unsupported_sentences` were computed every run and dropped
  on the floor; #334's commit message claims the latter was persisted. Now both
  are, along with a row for prose-fallback runs, which were the *only* runs
  leaving no trace — and they are the ones where chronology and attribution were
  trusted rather than checked.

Deliberately **not** done: `minItems: 1` on the evidence schema, which #332 asks
for. That constraint compiles to a decoding grammar, so a model unable to cite a
line would be forced to invent one — converting a visible "cites no transcript
line" flag into an invisible fabricated citation. The validator already flags
empty evidence.

Migration `f7a8b0c1d2e3`, applied and rolled back against a real database.

1,306 passing before, 1,324 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from verifying #331 and #334, one of which is a worker-killer.

## The fold recursion was unbounded

When the reduce prompt still exceeds the budget, `_summarise_chunked` folds its
own notes down by calling itself. That terminates only if each pass actually
shrinks the text — and a model is under no obligation to shorten what it is
given. A model answering a condense request with something the same length
recursed until the OOM killer took the worker, leaving the session in
"processing" forever with nothing logged.

Found by writing the first test for that branch, which killed the test runner
outright. The branch has existed since #331 and had no coverage at all; the
map-reduce test asserts `>= 2` windows, which a two-window transcript satisfies.

Bounded at three folds, then the notes are sampled to fit. `sample_evenly`
rather than truncation: no contiguous slice of a session's notes is the right
answer, and head-truncation drops the end of the session, which is the part a GM
most wants (#340).

## The beat list was described to the model as a transcript

When a session produces more verified beats than compose can hold, the list is
condensed first — and that pass was handed `_WINDOW_SYSTEM_PROMPT`, which tells
the model it is reading `'[HH:MM:SS] Speaker: text'` dialogue and asks it not to
speculate about what came before. A rendered beat list is
`[hh:mm:ss-hh:mm:ss] (kind) actors: summary`. Briefing the model on the wrong
document, at the one moment the pipeline is already over budget, is how a
condensed beat list turns into invented conversation.

`_summarise_chunked` now takes both system prompts, and the beat path passes one
that names what it is actually holding and repeats that nothing may be dropped —
the list has already been filtered twice, so there is no third sieve to apply.

## Overlap is now configurable

#331 asks for "configurable overlap"; `_WINDOW_OVERLAP_LINES` was a module
constant no caller, env var or setting could reach. Now a parameter with an env
override, the same shape as `QB_EXTRACTION_CONTEXT_CAP` and for the same reason:
the useful value moves with the model, and it should be settled with the #349
harness rather than by argument.

## Tests

"…and one that needs twelve" was met at splitter level only. There is now an
end-to-end test at a dozen windows asserting every one reaches reduce, in order —
losing one loses that stretch of the session silently — plus the fold recursion,
the beat-list prompt, and overlap at 0, default and 4.

1,324 passing before, 1,330 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last unmet criteria on #334 and #344.

## Timestamp anchors back into the transcript (#334)

Built in code from verified beats, never asked of the model — the compose prompt
forbids it mentioning time ranges, and a model writing its own citations is the
thing this milestone replaced. Each anchor points at a beat's *first cited line*
rather than its `t_start`: after #333's re-anchoring those usually agree, and
where they do not, the citation is the value that was checked.

Unverified beats get no anchor. An anchor invites a GM to go and look; sending
them to a timestamp for an event the validator rejected spends their trust on
the one claim already known to be unsupported. Those stay visible, with reasons,
on the runs API.

"Optionally" is satisfied by construction: the summary text is untouched and a
client renders the anchors beside it or not.

## The old path behind a setting (#334)

`summarisation_mode`: `beats` (default) or `prose`, following
`KEY_LORE_PIPELINE_MODE`'s pattern exactly.

Deliberately not the same thing as the automatic fallback, which already covers
"this provider cannot hold a JSON shape". This covers "the verified path is
doing something wrong on my data and I need last release's behaviour while it is
investigated" — a question a self-hoster cannot otherwise answer without editing
code mid-campaign. It defaults to the checked path, the GET endpoint says plainly
what choosing the other one gives up, and selecting it logs a warning both at
the moment of choosing and on every session summarised under it, so nobody has
to guess later why a session has no beats behind it.

## Recovering a name rather than flagging its absence (#344)

The last criterion asks that a cache miss "flags the session". Looking at where
the gap actually is, flagging turned out to be the weaker answer.

A speaker the bot could not name gets a placeholder in `speakers.json`. A linked
member *with* a character was already unaffected — the character name wins. An
*unlinked* speaker has nothing to recover and already surfaces to the GM through
`session.attendance_unmatched_speakers`, which is the attendance screen, exactly
where someone goes to say who an unrecognised speaker was.

That leaves one combination: a linked member with no character set. Their
placeholder became the transcript label while this database knew precisely who
they were. So the linked account's display name now fills in — but only when the
bot produced a placeholder, because the bot reads Discord directly and prefers
the guild nickname, which is normally what a table calls each other. Its answer
wins whenever it has one.

Also fixed the one bare-snowflake fallback #344's sweep missed, in the bot's live
dashboard. Lower stakes than the transcript — no LLM reads it as a person — but
the same unreadable string in front of the same GM, and there was already a
helper for it.

1,330 passing before, 1,341 after; bot 211, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The non-VAD path sent every speaker's WAV in one `/transcribe/session` request
with a JSON array of labels, then recovered attribution by mapping each returned
label back to the track it was sent for. That join is what #342 is about, and it
cannot be fixed by joining on a better key.

## Why the issue's own proposed fix would not have worked

#342 asks to "zip results back to tracks by an explicit identifier rather than by
position". Implementing that literally would have satisfied the criterion and
changed nothing: a permutation of ids survives an id-join exactly as a
permutation of names survives a name-join. *Any* echo-based join is unverifiable
in principle, because the only thing that could confirm it is the association
being asserted.

Commit 3ee27ff recorded the partial-swap case and called it unfixable by a test.
It understates the exposure. A **symmetric** swap — a server keying results by
upload position, so Kira's audio returns labelled "Bryn" and Bryn's labelled
"Kira" — puts every sent label back in the response. Every segment resolves,
every track receives segments, `check_every_speaker_was_transcribed` passes, and
the entire session is misattributed with nothing raised and nothing logged. That
is verbatim what #342's body names as the reason it exists, and it was still
fully live.

## The fix

One file per request, through `transcribe_track`, which stamps each segment with
the label of the file it sent. A response can only be about the file that was
sent, so there is nothing left to permute and no join to get wrong.

This is not a new design — it is what the VAD path has always done, and VAD is
on by default since #323. The safe shape was already shipping for most
deployments; this brings the last path in line. `/transcribe` per file is also
the ordinary Whisper contract; `/transcribe/session` was a custom multi-file
extension.

**Cost: N requests instead of 1**, against the same endpoint the VAD path already
calls many times per session, so it is a proven load shape rather than a new one.
Sequential, matching `transcribe_session_vad` and for its reason: a local server
has a small fixed slot count. Concurrency belongs with #356.

Worth flagging plainly since it changes how a supported configuration talks to
the GPU box: VAD-off sessions now make one request per speaker.

## A bug this uncovered

The non-VAD path never passed a language, because the multi-file endpoint took
none — so a campaign that had pinned one under #419 had it honoured on the VAD
path and **silently ignored** on the other. Per-file requests make it the same
call, so it now applies on both.

## Tests

The old tests pinned the join's behaviour (a diarization label raises, results
attributed by label not position) and describe a mechanism that no longer
exists. Replaced with tests for the mechanism that does — including the
symmetric swap, which could not be defended against before and is now simply
inexpressible, and the pinned language reaching every track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four criteria that were partial or unmet, found by verifying #335 against the
code.

## The single-pass lore path never chunked on boundaries at all

The multi-pass path packs whole `TranscriptSegment` rows, so a chunk can only
begin and end between utterances. Single-pass — admin-selectable — ran
`sample_evenly` straight over the raw transcript *characters*, cutting
mid-utterance freely. Exactly the defect #335 exists to prevent, on the path
nobody looked at.

`sample_evenly_over_segments` keeps the shape that matters (coverage across the
whole session, not the first twenty minutes) while only ever cutting between
whole lines. Sessions with no rows — everything before #335, deliberately not
backfilled — keep the old behaviour.

The test for it first asserts the *character* sampler does produce a mid-line
cut on that fixture, so it cannot pass vacuously.

## chunk_segments had silently dropped chunk_text's overlap

`chunk_text` repeats a slice of trailing characters into the next chunk "so
entities mentioned at chunk edges are not missed". The row-based replacement
took no overlap parameter, so every post-#335 session lost that property without
anything saying so. Now carried as whole segments — and **dropped rather than
padded** when it would push a chunk past its budget, since an overlap that
overflows the prompt reintroduces the risk the chunker exists to remove.

## Chunk text did not match the transcript for overlapping speech

`_render_segment_line` always emitted a single `[HH:MM:SS]`, while the stored
transcript renders simultaneous speech as `[start-end]` (#345). So the extractor
read a different rendering than the GM sees, for precisely the lines where two
people talked at once. `render_segment_lines` now computes overlap through
`audio_service.mark_overlapping` — the same function the transcript renderer
uses, not a second implementation of the rule — and the test asserts byte
equality with `render_transcript_from_rows`.

## The per-speaker QA query, and documentation

`transcript_qa_service` plus a GM-only endpoint. It says plainly what it cannot
do: audio duration per track is not stored beside the rows, so it can show a
speaker whose transcript looks thin but cannot confirm "this track had speech and
was not transcribed". Raising the suspicion is not resolving it.

`docs/API.md` gains the transcript-edit semantics (#335 asked for them documented
and tested; they were tested and living in code comments), and the three
endpoints that were undocumented — both `summarisation-runs` from #333/#424 and
the new one.

## Two test corrections on review

`test_zero_overlap_segments_reproduces_the_plain_packing` asserted the opposite
of its name — that the outputs *differ*, which is not "zero overlap is exact".
Renamed to what it actually checks, which is a worthwhile guard in its own right:
that the default is not silently 0.

Two existing tests were pinned to `overlap_segments=0` because "each segment
appears in exactly one chunk" is false by design once overlap is on. That is
correct, but it left the *shipping* path with nothing asserting no line is ever
cut — the property #335 is about. Added back for the default.

1,346 passing before, 1,362 after; bot 211, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`webapp/CLAUDE.md` still named `c4d5e6f7a8b0` as the head and stopped at entry
79, so it was three migrations behind — `d5e6f7a8b0c1` from the beats work, plus
the two added while closing #332/#333/#334/#338. The file asks to be kept in sync
and is the thing you read before a deploy to find out what is about to run, so
being stale there is worse than being absent.

Prod is on `f1a2b3c4d5e7`, which makes the pending set six, not the four the
list implies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four live runs on the private fixture at 9dd7122, qwen3.5 on the GPU box, for
the release decision.

Compared against e992456 — this session's exact starting point, measured the
same way — today's work is neutral on accuracy:

    metric           session start        now
    coverage         0.727/0.795/0.818    0.727/0.773/0.818
    attribution      0.667/0.774/0.875    0.625/0.705/0.778
    chronology(tau)  0.929/0.954/1.000    0.857/0.950/1.000
    beat validation  0.857 (spread 0)     0.857 (spread 0)
    cov.in beats     0.818 (spread 0)     0.818 (spread 0)

The two zero-variance rows are the load-bearing ones. Extraction finds the same
9 of 11 events on every run before and after, and validation confirms the same
fraction — so nothing this session touched changed what the pipeline extracts or
what survives checking. Coverage and attribution move because *compose* is prose,
and prose is deliberately not greedy-decoded (#232, #423); that is where all the
run-to-run variance in this harness lives.

Which also refutes the hypothesis worth recording: the loop-termination fix
looked like it might cost coverage by ending extraction earlier. It cannot.
Extraction runs at temperature 0, so a second pass returns the beats the first
did, `gained` is 0 after dedupe either way, and the loop stopped at
MIN_BEAT_EXTRACTION_PASSES before and after. Raising that constant would buy
nothing on this fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`APP_VERSION` and `BOT_EXPECTED_APP_VERSION` to 4.0.0.

**`BOT_CONTRACT_VERSION` stays 1**, deliberately. The only `/api/bot/*` change
in this release is #397's intake guard, and 5ede536 already reasoned it through:
the request change is additive, and a previously-202 case returning 409 is
handled by an older bot as "could not submit", which is true, harmless and the
safest available degradation. Forcing every self-hoster into a lockstep image
upgrade for a slightly wrong message in a rare case is disproportionate. Nothing
since has touched the contract — `routers/bot.py` is unchanged across the whole
branch, and the two new endpoints are on `admin.py` and `sessions.py`.

`make check-versions` passes at 4.0.0 / contract 1.

## The changelog is written for someone deciding whether to upgrade

Not as a commit list. The four things a self-hoster has to act on are stated
before the feature summary:

- **six migrations land at once**, only one of which backfills, and it is
  expand-only so a downgrade drops two tables and risks nothing;
- **the contract stays at 1**, so images can move one at a time;
- **VAD-off installs change request volume** — one transcription request per
  speaker instead of one per session, because attribution can no longer depend
  on a label the server echoes back;
- **nothing is backfilled** into the new structure, so old sessions keep working
  and simply have no provenance to show.

Verified the release workflow can extract the section: its own regex from
`.forgejo/workflows/release.yml` matches 7,343 characters bounded correctly at
the 3.11.5 heading. OPERATIONS.md warns that a mis-shaped entry publishes an
empty release body, which is not something to find out from the tag.

1,362 backend and 211 bot tests green; six migrations rehearsed against a copy
of production data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(backend): stop a silent tail destroying a good transcript (#431)
Some checks failed
CI / Backend lint (ruff) (pull_request) Failing after 38s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m13s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m17s
CI / Bot/backend version sync (pull_request) Successful in 27s
CI / Backend migration, tests, and audit (pull_request) Failing after 1m48s
CI / Bot tests and audit (pull_request) Successful in 1m49s
CI / Docker image build (pull_request) Successful in 2m46s
54116c28f7
check_transcript_covers_session compared the transcript's last timestamp
against the recording's wall clock. Every track is tail-padded with silence
to that clock, and nothing stops a recording when the voice channel empties
— there is no voice_state_update listener, only the six-hour cap. So the
ratio was speech-end over wall-clock, which measures nothing, and a table
that played for two hours and forgot to stop for another fifty had its
complete, correctly attributed transcript rejected at 58%.

Rejected after transcription, so the GPU work was already paid for and the
segments were already correct; deterministically on every retry, because the
duration is re-derived from the same padded tracks; and with a message
blaming the capture clock, sending whoever read it into working code.

v4.0.0 made this worse rather than introducing it. The guard predates the
release, but admin retry used to pass duration 0, which skipped it — so a
long-tailed session that failed could at least be recovered. #421's
derive_session_duration arms the guard on exactly that recovery path.

Coverage is now measured against how far into the session the tracks carry
audible speech, via last_audible_second — the mirror of has_audible_speech,
scanning backward for the same reason that one scans forward. Tracks is a
required argument, not optional: defaulting it would let a caller silently
measure against the clock again. Where no track can be measured the check
skips rather than guessing, because the shape it uniquely catches cannot be
produced by capture since #320 placed every track against a single t0.

The regression test was run against the pre-fix guard body and confirmed to
fail there, and a genuinely truncated transcript still raises — widening the
denominator is an easy way to disable a guard by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(backend): repair two CI gates this branch never ran against
All checks were successful
CI / Frontend tests, audit, and build (pull_request) Successful in 1m24s
CI / Backend lint (ruff) (pull_request) Successful in 25s
CI / Docker image build (pull_request) Successful in 13s
CI / Bot/backend version sync (pull_request) Successful in 21s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m5s
CI / Bot tests and audit (pull_request) Successful in 1m38s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m8s
edc6ea2306
CI only triggers on push and pull_request to main, so none of this branch's
53 commits had been through it. Opening the release PR ran it for the first
time and two jobs went red.

Backend migration, tests, and audit
-----------------------------------
That job runs `alembic upgrade head` before pytest, so pytest meets the
migrated schema rather than a create_all one. conftest opened with
Base.metadata.drop_all, which can only drop what the ORM knows about and
orders those drops from the foreign keys declared in the metadata.

#330 put those two sets out of step deliberately. It keeps the five
campaign_members.character_* columns in the database — expand-only, so a
downgrade risks no data — while replacing them in the ORM with read-only
properties over the active CampaignCharacter. On main
character_lore_entry_id is a mapped column carrying an explicit ForeignKey
to lore_entries; here it is a property, so the physical constraint
fk_campaign_members_character_lore_entry_id_lore_entries still exists while
the metadata edge describing it does not. drop_all therefore emitted
DROP TABLE lore_entries before dropping campaign_members and Postgres
refused.

Dropping the schema outright sidesteps the class rather than this instance,
and takes stale enum types with it as well. The pattern that caused it —
retain the columns now, drop them in a later contract migration — is
deliberate and will recur, so the reset must not depend on the ORM agreeing
with the database.

Verified by reproducing CI exactly against a clean migrated database: the
previous conftest fails there with the identical DependentObjectsStillExist
error, and the suite passes with this one. 1,369 tests pass on both the
migrated and the create_all path.

Backend lint (ruff)
-------------------
CI lints webapp/backend/ and scripts/ at a pinned ruff 0.4.4. The local
loop runs `ruff format app tests evals`, which does not cover alembic/, so
d5e6f7a8b0c1_session_beats.py had never been formatted by either. Formatted
with 0.4.4 specifically — a newer ruff considers the file already correct,
which is how it survived local runs. The change is one collapsed line; the
migration is unaltered semantically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.