# ADR-v2-134 — Meeting transcript quality gate (two transcripts, one verdict)

**Status:** Accepted (2026-08-26) · Wave P
**Context links:** [[adr-v2-127-live-meeting-mode]] (adds the gate to its finalize path; the
live transcript it already streamed becomes a first-class artefact),
[[adr-v2-128-meeting-minutes-generation]] (the gate suppresses the notes pass),
[[adr-v2-125-diarized-recording-import]] (shares the batch pipeline this judges),
[[adr-011]] (on-device; the recording-retention default is a privacy decision this narrows
deliberately), [[adr-021]] and [[adr-v2-065]] (guards judged on how rarely they fire)

## Context

ADR-v2-127 chose a **hybrid** design: a rolling live decode during capture, and one accurate
batch decode of the whole recording at stop. It also chose to **delete the recording once the
post-pass has consumed it** (`[meeting] retain_audio = false`), which is the right privacy
default and was made safe by deleting *after* success rather than before.

That safety rests on an assumption nobody had written down: **that a post-pass which returns
without raising has produced a record of the meeting.** It has not, and the failure is not
exotic. Autoregressive decoders are known to fall into repetition loops on long audio, emitting
one phrase for the remainder of the file. The output is syntactically perfect and structurally
complete — timestamps, word alignment, the lot — so it raises nothing.

Observed on a maintainer machine, 2026-08-26, meeting `20260826-100205`:

- 41 min 39 s of a real four-person call.
- `transcript.md`: 284 words, 93 consecutive repetitions of "Hello, hello, hello."
- `meeting.json`: `status: "done"`, `capture: "ok"`, `attribution_suspect: ""`.
- The recording was therefore deleted as a successful consumption.
- `live.jsonl` held **4553 words** of the actual meeting — and nothing in the product
  rendered it, listed it, or mentioned it for a finished meeting.

Every guard that exists passed, and each was right to. `capture_state` asks whether audio was
**heard**; it was. `attribution_suspect` asks whether speaker labels can be trusted; there was
one cluster and nothing was mis-attributed. Neither asks whether the *words* are real. Nothing
did, because until this failure "the post-pass returned" and "the meeting was transcribed" were
treated as the same statement.

The consequence is the severe part. A bad transcript is recoverable while the audio exists and
permanently unrecoverable once it does not, and the deletion was gated on precisely the signal
that had failed.

## Decision

**1. A finished transcript is judged, and the verdict is recorded.** New pure module
`meeting/quality.py` (stdlib only, no models, no I/O) returns `ok` / `degenerate` / `thin` /
`unjudged` from four signals:

| signal | fires on |
|---|---|
| top n-gram share ≥ 0.20 | one phrase dominating the transcript |
| distinct n-gram ratio ≤ 0.35 | a transcript with almost no distinct content |
| longest back-to-back repeat ≥ 12 | a collapse that begins *late* in an otherwise healthy meeting |
| < 25 wpm over ≥ 300 s | a long recording that decoded to almost nothing |

Thresholds were fixed **against the five real stored meetings on the machine where the failure
happened**, before any was chosen, and sit clear of both edges rather than against the one bad
sample:

| meeting | duration | wpm | top trigram | distinct | verdict |
|---|---|---|---|---|---|
| 20260710-212029 | 56.7 s | 106.9 | 0.0101 | 1.0000 | ok |
| 20260803-095635 | 8081.4 s | 117.9 | 0.0049 | 0.8210 | ok |
| 20260814-065156 | 26.6 s | 2.3 | — | — | unjudged |
| 20260819-033515 | 11.6 s | 0.0 | — | — | unjudged |
| **20260826-100205** | **2499.7 s** | **6.8** | **0.9681** | **0.0355** | **degenerate** |

Recall 1/1, false alarms 0/4, measured through the shipped CLI on real data — not only in
tests. Two orders of magnitude separate the collapsed decode from the worst healthy one.

**2. The strongest signal needs no threshold at all.** ADR-v2-127 already decodes the same
audio twice. On the collapsed meeting the live pass holds 4553 words against the batch pass's
284 — **16×**; on the healthy 2 h meeting the two agree to **1.008×**. A second independent
opinion on the same input beats any statistic computed from one, so the ratio (≥ 3× with ≥ 20
batch words) is a first-class signal and is reported in its own words.

**3. The live transcript is promoted from recovery format to second artefact.** `live.jsonl`
is rendered to `live-transcript.md`, timestamped, **before** the batch pass runs — a finalize
that dies never reaches a line placed after it. It is written for every meeting and deleted by
nothing.

*Extended 2026-08-26:* the same file is now **appended to as each utterance is decoded**, not
only rendered at stop. The recovery guarantee was already met by `live.jsonl`; what was missing
is that nobody reads newline-delimited JSON, so a transcript that existed throughout a two-hour
meeting was unreadable until it ended. The incremental writer and the whole-file re-render must
produce byte-identical output for the same records (`_live_markdown_block` is the shared unit,
and `test_appending_matches_a_full_re_render` holds them together) — otherwise every finalize
silently rewrites the file the user has been reading, and "what I watched" and "what was saved"
differ for no reason anyone could explain. `live.jsonl` stays the source of truth precisely so
the re-render can *repair* an append torn by a crash. Off with `[meeting] live_markdown = false`.

**4. Deletion is gated on the verdict, not on the absence of an exception.** The recording is
kept whenever the transcript is suspect, regardless of `retain_audio`. This narrows the ADR-011
privacy default in exactly one direction and only in the case where the alternative is
destroying the only copy of a meeting.

**5. Minutes are suppressed on `degenerate`/`thin`**, joining the `no speech` rule ADR-v2-128
already had, and for its stated reason: a summary of invented words is the one artefact nobody
can audit afterwards. Deliberately **not** extended to live-disagreement alone — there the
batch transcript is short but real, so its minutes are incomplete, not fabricated.
`meeting notes --force` overrides.

**6. `status: "done"` stops being the whole story.** `meeting recover` accepts a meeting that
finished *badly*, not only one that never finished, and **archives** prior outputs to
`attempts/<n>/` rather than overwriting them — a retry runs precisely when the last result was
distrusted, and the retry can be worse. Nothing is ever deleted.

**7. The verdict reaches a human without a terminal.** A meeting has no key held and no window
watched; its post-pass ends long after the user has walked away. One readout — built once,
from the metadata just written — is printed by `yazses meeting summary`, written into the folder
as `summary.md`, and shown as a desktop notification. A post-pass that *fails* notifies too.

**8. The repair applies backwards.** `store.ensure_quality` computes a verdict from a stored
transcript on demand and writes it back, so meetings recorded before the gate existed — the
broken one among them — are judged rather than reported blank.

## Consequences

- **Every meeting now costs one extra rendered file and one JSON record.** `quality.json` is
  written for healthy meetings too, on purpose: a verdict is only interpretable next to the
  numbers of the meetings it did not fire on.
- **The privacy default is weaker in one measurable case.** A meeting whose transcript fails the
  gate keeps `audio.wav` until the user acts. This is stated in the CLI, in `summary.md`, and in
  the notification, so it is never silent. It is a deliberate trade: ADR-011 protects the user
  from us, and an unrecoverable meeting harms the user directly.
- **Thresholds are corpus-bound and will need re-measuring.** Five meetings from one machine,
  one language, one model (`base.en`). A different model or a very short/very sparse meeting
  culture could move the healthy edge. The metrics are persisted for exactly this reason: the
  corpus needed to re-fit them accumulates automatically.
- **`recoverable` no longer means "unfinished".** Any surface reading it as a synonym is now
  wrong — one did, and listed a finished meeting as `unfinished`.
- **The gate cannot detect a decode that is wrong but fluent.** A transcript that hallucinates
  plausible sentences at a normal rate passes every signal here. This gate catches *collapse*
  and *silence*, not *inaccuracy*, and must not be described as a correctness check.

## Alternatives considered

- **Keep the recording always** (flip `retain_audio` to true). Simplest, and rejected: it makes
  every meeting a permanent audio file on disk by default, which is a much larger privacy change
  than the failure justifies, and it fixes recoverability without ever telling the user their
  transcript is wrong.
- **Detect the loop inside the decoder** (`condition_on_previous_text=False`, compression-ratio
  thresholds, `no_repeat_ngram_size`). Worth doing as *prevention* and orthogonal to this ADR —
  but it is engine-specific, does not survive an engine swap (`[stt] engine = parakeet`), and
  cannot notice a collapse it fails to prevent. A verdict computed from the finished text works
  for every engine, including ones not yet written.
- **Merge the two transcripts** into one best-effort text. Rejected: it manufactures a third
  artefact that is neither decode, hides the disagreement that is itself the finding, and makes
  "which of these is the record?" unanswerable. They stay separate and the user is told which
  to read.
- **Fail the finalize outright** on a bad verdict. Rejected: the collapsed transcript is
  evidence, the diarization and timings around it may still be useful, and an exception here
  would land in the one place (a `daemon=True` finalize thread) where it is least visible.
