Two corrections from the operator, and I had both wrong.
1. "I didn't explicitly approve all minor character voices. By releasing the
audiobook I have given implicit approval those voices are ready and should be
tagged." The previous session reported "24 of 61 speakers have an approval
record; 38 do not" as if it were a gap. It is not a gap. Releasing IS the
approval, and those voices were waiting to be tagged.
chapter_approved and audiobook_approved now read true, with
approved_by: "this publication record" - there is no separate audiobook
approval in this system, and pretending otherwise understated the release.
The boundary matters, so it is measured rather than assumed: implicit
approval covers voices that HAVE LINES in the released chapters, computed from
the database. A character who never speaks is not covered, because releasing a
book does not approve a voice it never uses. On Deal Hunter edition 1 that is
24 explicitly approved, 29 implicitly approved, and 10 in-profile voices with
no lines. The three categories are never summed - an explicitly approved voice
can also be one that stopped speaking.
grant_implicit_voice_approval() writes plain status strings, the shape the web
UI already reads, and keeps the provenance in the immutable publication record
rather than the mutable ledger, so it cannot be edited away.
2. The m4b inter-chapter gap should be the same 2 s as the lead and trail
padding. export.py had gap_ms=4000 in one path and a default of 1000 in the
other, while ar_export.SILENCE_MS = 2000 governed lead and trail: three
drifting values for one concept. The gap now imports SILENCE_MS as
CHAPTER_GAP_MS, so all three are one number that changes in one place.
New umbraaudio/release_comments.py - the comment is a gate, not a caption.
designator 2026-10-03 #2 14:32:05
slug release-20261003-143205-02
The sequence is per date, not global, which is the whole point: a problem found
and corrected on the same day is unambiguous about which was last. Two comments
authored in the same second still get distinct slugs, because the sequence is
allocated by scanning the directory. First line is the summary, the rest is the
body, like a commit message. An approved comment is FROZEN - changing an approved
note is a different claim about a different state, so it needs a new comment and
therefore a new publication.
require_approved() blocks --publish: passing a draft exits 2 and says what to do
next. Verified live.
draft_comment_text() composes the comment from the measured build rather than
from memory. Deal Hunter's draft is written and waiting:
release-20261003-133508-01, 32 chapters, the 24/29/10 voice split, 32/32
loudness and peak gates green, and the post-approval padding note.
New invariants R-8 through R-11 in spec 22. Two of my own test premises were
wrong and the implementation was right - recorded in AGENTS.md because the tests
were the weaker artefact.
Full suite 538 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
Nothing was published: the Deal Hunter comment is a draft awaiting review, and
voice_status.json is untouched because publishing never ran.
The operator clarified the release model: the system keeps current and previous
state, there must be an explicit publish flag the user sets, there are approvals
for a speaker voice but none for the audiobook or chapters, the chapters that
make the m4b are the versions to flag, and a fix found after assembly causes a
change rather than editing what shipped.
Deal Hunter is the precedent, and it is measurable rather than theoretical.
Against the real package (built_at 2026-09-09T11:21:11Z), chapter 6:
ch6_full.wav 2227.26 s @ 24000 Hz what a reviewer listens to
Deal_Hunter_01_Salvage.mp3 2231.26 s @ 44100 Hz what a listener received
+4.00 s = 2 s lead + 2 s trail
plus a resample and a -20.5 dB RMS normalization. Found on 32 of 32 chapters.
The approved artifact and the published artifact were different files, and
nothing in the system recorded either fact. ar_package.json is rewritten by
every rebuild, so it is a build status, not a release record.
umbraaudio/release.py
DeliveryProfile.from_ar_package reads the profile the exporter actually used,
so the record follows the code instead of a declaration that can rot.
build_publication is read-only and pins every chapter wav, every mp3 and the
package by full SHA-256, plus a per-chapter source_unit_key_digest so a release
pins which source revision produced it, not merely which bytes.
verify_publication returns ok / drift / warnings and never silently accepts a
changed artifact. write_publication is atomic and refuses to overwrite an
existing publication id - immutability enforced by the writer, not by
convention. A later fix is edition n+1 with supersedes pointing at n.
The rule that prevents a repeat: you cannot approve a chapter and then pad it.
An approval must name the artifact a listener receives. Every record states
chapter_approved: false and audiobook_approved: false in its own words, because
that is what would have caught Deal Hunter.
The record also refuses to overstate approval. voice_status.json is an approval
LEDGER - it only holds speakers someone explicitly touched - so it has 24 entries
against 61 speakers in the profile. Reporting "24 approved" as if it meant "all
approved" would claim an approval nobody gave, so the counts are separate and the
38-speaker gap is named.
scripts/publish_release.py: dry run by default; --publish, --verify, --list,
--edition/--supersedes. Exits 2 while blockers exist.
Also found, reported and NOT fixed (specs/22 section 7 gap 1): export_m4b's
_stitch_chapter hard-codes 500 ms/0 ms silence while ChapterBuilder uses
AudioStitcher defaults and AudioConfig holds its own values, so the m4b path can
stitch a chapter with different silence than the approved one. Until that is
closed an m4b release record would pin the wrong bytes, so it is better not to
offer one yet.
And projects.status is never advanced to 'released' in any of the four live
projects - all sit at parsed/analyzed - so the publication record is currently the
only release signal in the system.
tests/test_release.py: 24 tests. Full suite 512 passed / 8 failed, the same 8
pre-existing failures. ruff clean. No export, TTS or project data was modified.