# v2.32.0 — the release that stopped trusting a clean return

**Released 2026-08-26.** Upgrade:

```sh
sudo snap refresh yazses     # snap
pipx upgrade yazses          # or: uv tool upgrade yazses
```

33 commits. Most of the defects fixed here share one shape: the operation finished
without raising, reported success, and destroyed or discarded the thing it existed to
produce. A clean return was treated as evidence, and it was not.

## A meeting no longer disappears into a collapsed transcript

A real 41-minute meeting finalized as `status: "done"`, `capture: "ok"`, with a
`transcript.md` containing 93 repetitions of "Hello, hello, hello." The decoder had
collapsed into a repetition loop, and every existing guard passed it: one asks whether
audio was *heard* — it was — and the other asks who said what, and there was one
speaker. Neither asks whether the words are real. Because nothing raised, the recording
was deleted as a successful consumption, and the only surviving record was a
newline-delimited JSON file that nothing rendered and nothing mentioned.

Every meeting is now transcribed twice and both are kept. The rolling live decode is
written to `live-transcript.md` on the way out — before the batch pass runs, so a
finalize that dies still leaves it. A new quality check scores the batch transcript on
repetition share, distinct-phrase ratio, longest back-to-back repeat, words per minute,
and how far the batch decode disagrees with the live decode of the same audio. That last
signal is the strongest and needs no threshold.

Thresholds were fixed against five real stored meetings before being chosen: the check
catches the collapsed one and fires on none of the four healthy ones. The metrics are
written to `quality.json` for every meeting, healthy or not, because a verdict is only
interpretable next to the numbers it did *not* fire on.

When the verdict is bad the recording is kept regardless of `[meeting] retain_audio` —
a deliberate, one-directional narrowing of the privacy default, because the recording is
the only input that can produce a better transcript. The minutes pass is skipped, since
a summary of invented words reads exactly like a real one. And `yazses meeting recover`
now accepts a meeting that finished *badly*, not only one that never finished; it
archives previous outputs to `attempts/<n>/` rather than overwriting them, because a
retry runs precisely when the last result was distrusted and it may come back worse.

## Follow a meeting while it is still running

`live-transcript.md` is now appended to as each utterance is decoded rather than being
rendered once at stop. The record already existed throughout the call, but as JSON Lines
nobody opens mid-meeting. `yazses meeting start` prints the path and the `tail -f`
command; `yazses meeting status` names the same file. The incremental writer and the
whole-file re-render produce byte-identical output, so the finalize pass never rewrites
the file you have been reading. Disable with `[meeting] live_markdown = false`.

A new `yazses meeting summary` says what the meeting produced, where each file is, what
each file is *for*, and — first, above the file list — anything that should stop you
reading the transcript as a record. The same readout is written to the folder and sent
as a desktop notification, because Meeting Mode has no key held and no terminal watched,
and its post-pass ends long after the user has walked away.

## The snap crashed on the first command it told users to run

`yazses setup` executed `sudo` to install host packages and add the user to the `input`
group. Inside a strictly confined snap none of that is permitted, and the command died
with `PermissionError: [Errno 13] Permission denied: 'sudo'`. It now detects confinement
and prints the two `snap connect` commands that actually apply.

The condition was reproduced from the published 2.31.0 snap in a clean LXD container,
which is the only way to meet it on purpose: a developer machine runs from a checkout and
never enters confinement. That is why the crash reached a user before it reached a test.

Every surface that described the snap has been corrected to match. The Store description
and install docs no longer present `yazses setup` as a host-provisioning step; snap
dictation is labelled X11-only wherever it is offered, with Wayland pointed at the
universal installer; and the install checklist now carries `snap connect
yazses:raw-input`, which it had never mentioned — so the one screen telling a user what
to do omitted the only step that grants the hotkey. `yazses quickstart` also stops
reporting "Prerequisites — already set up ✓" inside a snap whose interfaces are
unconnected: a confined plan is empty because nothing in it is the app's to do, which is
the opposite of a provisioned machine.

Related: `yazses setup` no longer exits with a traceback when a command cannot be
executed at all. `check=False` suppresses a non-zero exit status, not a failure to
`exec`, so a sandbox denial or a missing binary escaped out of the one command whose job
is repairing a machine that does not work yet.

## Publishing found the revision and threw it away

Both architectures uploaded successfully in the v2.31.0 publish run, and neither reached
`snapcraft release`. Under the restored `set -e`, a failing command substitution in an
assignment aborts the step — so one transient non-zero exit from `snapcraft revisions`
killed the job on the first pass of the very loop written to retry it. The workflow now
captures the table through an `if`, making a failed query a skipped iteration rather than
a dead job.

Separately, nothing ever asked a *second* time whether a release had shipped. The
existing check runs at the tag push, which is the one moment a slow channel legitimately
answers "not yet" — so a stall was indistinguishable from a release in flight. A
scheduled watcher now re-asks after a grace period and compares against the previous
release rather than a hand-written list of channels, so it reports a channel that went
*backwards* instead of filing the same six permanently-absent channels every day.

## Measured: the decode defaults were put to a 2×2, and both are kept

`large-v3` decoding the same 200 `test-other` utterances five times produced five
different WERs (4.84–6.21%). The obvious repair was greedy decoding, and it was about to
be recommended. Measuring the full 2×2 — temperature fallback crossed with
`condition_on_previous_text`, five decodes per arm, on `large-v3` *and* on the `base.en`
a default install actually runs — says otherwise, and no default changes. Greedy decoding
is the worst arm measured anywhere: 15.26% on `large-v3`, almost all of it insertions.

Turning conditioning off is what actually fixes `large-v3` (3.82%, one hash across five
decodes), but it reverses on `base.en`, which gets worse on both splits. The measurement
also found a knob users could not reach, so `[stt] condition_on_previous_text` is now
exposed — and the follow-up sweep gives it a rule rather than a preference: the benefit
shrinks monotonically with model size and hits exactly zero at `medium.en`, where all ten
decodes across both arms return a single hash. Leave it alone below `medium.en`, set it
`false` on `large-v3` and above.

One documentation claim did not survive contact with the project's own archive: the
config reference called larger checkpoints a "marginal gain on clean speech", while the
stored numbers say `small.en` removes 36% of `base.en`'s errors on clean audio and 41% on
hard audio. `base.en` remains the default — it is the best *latency* trade for
hold-to-talk — but for the true reason.

## Also in this release

- `[stt] language` was a documented key that did nothing for a year, because all three
  decode paths hardcoded English. It is now threaded through the single seam they share,
  and a test fails any decode path that builds its own arguments.
- A disfluency self-correction trigger ate the sentence before it in ordinary prose.
- The diarization plausibility guard was near-inert on real meetings — it had been
  measured on a corpus of a different shape.
- Sitemap entries now carry each page's own last-commit date instead of claiming all 475
  pages changed on every build, and four design-index URLs that appeared twice were
  de-duplicated.

The full list — every change, measurement and limitation — is in
[CHANGELOG.md](https://github.com/MSKazemi/yazses/blob/main/CHANGELOG.md).
