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.
15 KiB
Spec 22 — Releases: the Publish Flag, Pinned Artifact Sets, and Post-Publication Change
- Status: implemented on the UmbraAudio node (backend + CLI + tests). Awaiting ratification by the controller.
- Date: 2026-10-03
- Origin: an operator clarification of the release model, prompted by Deal Hunter.
- Companions:
specs/21(nodes, tasks, changes),specs/20§F (editions,edition_artifacts, invariant I-H), controller decisions D9/D12/D14/D15.
1. The clarification, stated back
The system, with time markers, acts as a git repository: it stores the current state and keeps the previous ones. There should be an explicit flag. The user tells the system this is the version that was published, making explicit all the items and approvals that make up the final state. There are approvals for items like a speaker voice, but not for the full audiobook and chapters. The chapters that make an m4b are the versions to flag. After assembly into final chapters and m4b, other issues may need fixing; the publishing requirements must then be met, and that causes a change. Deal Hunter experienced this when 2 seconds of dead sound had to be added to the beginning and end of each chapter, after approval.
Five consequences, each implemented:
- A release is a pinned set, not a build directory. Every chapter artifact and the package carry a full SHA-256 in the release record.
- The publish flag is the release record. Writing it is the approval — because no other artifact-level audiobook approval exists.
- The pinned set is the chapters that make the m4b, not the loose utterances and not the pre-encode intermediate.
- Everything applied after a chapter is approved is part of the release, and is recorded in it. Delivery profile: sample rate, channels, bitrate, CBR, leading/trailing silence, loudness target, gain limit, peak ceiling.
- A change after publication is a successor, never an edit. A published record is
immutable; a fix produces edition n+1 with
supersedespointing at n.
1.1 Follow-up clarifications, also implemented
Not every minor character voice was explicitly approved. By releasing the audiobook the user has given implicit approval that those voices are ready, and they should be tagged. The dead audio gap between chapters in the m4b should be the same 2 seconds. At the designated release, add a comment like a git commit comment — one the user can see, change and approve. This adds a step to the release, makes it explicit, and gives a designator to go back to. The comment should carry date, time, and a sequence number for that date (1, 2, 3) so a problem found and corrected the same day makes it clear which was last. Deal Hunter should receive the comment based on the last m4b build.
- Releasing approves the voices a listener hears. The rest of the cast is implicitly approved by the release and is tagged. §4.3.
- One audible-pause value. The m4b inter-chapter gap is now the same 2 s as the lead and trail padding, imported from a single constant so they cannot diverge again. §4.4.
- A release requires an authored, approved comment. A gate, not a caption, with a date + time + per-date-sequence designator. §4.2.
2. What was already wrong, measured
Not hypothetical. On the published Deal Hunter package (ar_package.json,
built_at 2026-09-09T11:21:11Z), chapter 6:
| artifact | duration | rate | what it is |
|---|---|---|---|
chapter_audio/ch6_full.wav |
2227.26 s | 24 000 Hz | what a reviewer listens to |
export/ar_package/Deal_Hunter_01_Salvage.mp3 |
2231.26 s | 44 100 Hz | what a listener received |
+4.00 s = 2 s leading + 2 s trailing digital silence, plus a resample and a loudness normalization to −20.5 dB RMS. Applied to 32 of 32 chapters.
The approved artifact and the published artifact were different files, and the system
recorded neither fact. ar_package.json is rewritten by every rebuild, so it is a build
status — not a release record.
A second, independent instance of the same class, still latent: export_m4b._stitch_chapter
hard-codes silence_between_utterances_ms=500, silence_between_chapters_ms=0 while
ChapterBuilder uses AudioStitcher() defaults and AudioConfig holds its own values. The
m4b path can therefore stitch a chapter with different silence than the approved one. Reported,
not fixed — see §7.
3. The rule that actually prevents a repeat
You cannot approve a chapter and then pad it.
An approval must name the artifact a listener receives. Every transform applied between "chapter finished" and "bytes shipped" is part of the release and is recorded in it. Where a transform necessarily changes the bytes — padding, resampling, loudness normalization — the approval is taken on the post-transform artifact, or it is not an approval of what ships.
4. What is implemented
4.1 umbraaudio/release.py
| function | purpose |
|---|---|
build_publication(...) |
Captures the pinned set + approvals + delivery profile. Read-only — hashes files, writes nothing. |
DeliveryProfile.from_ar_package(...) |
Reads the profile the exporter actually used from its own manifest. Measured, not declared. |
verify_publication(...) |
Re-checks a record against the filesystem → ok / drift / warnings. |
write_publication(...) |
Atomic (tmp + os.replace), and refuses to overwrite an existing publication id. |
list_publications(...) |
Every published edition. |
A publication record:
{
"publication_id": "pub-001-2026-09-09",
"work": "...", "edition_number": 1, "supersedes": null,
"published_at": "...", "published_by": "...", "git_commit": "...",
"delivery_profile": {
"sample_rate": 44100, "channels": 1, "bit_rate_kbps": 192, "cbr": true,
"lead_silence_s": 2.0, "trail_silence_s": 2.0,
"target_rms_db": -20.5, "max_gain_db": 12.0, "peak_ceiling_dbfs": -3.0
},
"package": { "object_key": "export/Deal_Hunter.m4b", "sha256": "...", "bytes": 325875309 },
"approvals": {
"voices_in_profile": 61, "voices_approved": 24, "voices_not_approved": 38,
"voice_status_digest": "...",
"chapter_approved": false, "audiobook_approved": false
},
"chapters": [
{ "chapter_id": 6, "seq": 6, "title": "1 Salvage",
"chapter_wav": { "object_key": "chapter_audio/ch6_full.wav",
"sha256": "...", "duration_s": 2227.26, "sample_rate": 24000 },
"mp3": { "object_key": "export/ar_package/Deal_Hunter_01_Salvage.mp3",
"sha256": "...", "duration_s": 2231.26,
"rms_db": -20.5, "peak_db": -3.0,
"loudness_ok": true, "peak_ok": true },
"source_unit_key_digest": "...", "utterances": 364,
"warnings": ["shipped track is 4.0s longer than the approved chapter wav ..."] }
],
"blockers": [], "publishable": true
}
Two fields carry most of the weight and are worth calling out:
source_unit_key_digest— a digest of everysource_unit_keyin the chapter (specs/21§10). A release therefore pins which source revision produced it, not just which bytes. Audio and source are traceable to each other after the fact.voices_not_approved: 38—voice_status.jsonis an approval ledger: it only holds speakers someone explicitly touched, so it has 24 entries against 61 speakers. Reporting "24 approved" as if it meant "all approved" would claim an approval that was never given, so the two counts are separate and the gap is named.
4.2 Release comments — umbraaudio/release_comments.py
A git-commit-style note with a designator you can quote later.
designator 2026-10-03 #2 14:32:05
slug release-20261003-143205-02
author worstauthor
status APPROVED
Deal Hunter edition 1: 32 chapters pinned
package export/Deal_Hunter.m4b (325875309 bytes, built 2026-09-09T11:21:11+00:00)
delivery 44100 Hz / 1 ch / 192 kbps, +2.0s lead and +2.0s trail, -20.5 dB RMS
- The sequence is per date, not global. Two releases on one day are
#1and#2, so "which was last" is answerable without ambiguity — the reason the designator exists. 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, exactly 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 a new publication — the same rule the publication itself follows.
- Publishing is gated on an approved comment.
--publishwith a draft exits 2 and says what to do next. draft_comment_text()composes the comment from the measured build, so it states what shipped rather than what someone remembers shipping. Deal Hunter's draft is written and waiting for review.
4.3 Implicit voice approval on release
Releasing the audiobook is the approval of every voice a listener hears in it. On publish,
grant_implicit_voice_approval() tags those voices approved in voice_status.json and
records the provenance — which publication, on what basis — inside the immutable publication
record, where it cannot be edited away.
Measured on Deal Hunter, edition 1:
| category | count | meaning |
|---|---|---|
| explicitly approved | 24 | someone reviewed them in the UI |
| implicitly approved by this release | 29 | they speak in it; releasing accepts them |
| in the profile, no lines | 10 | not covered — releasing a book does not approve a voice it never uses |
Those three categories are deliberately never summed: an explicitly approved voice can also be one that no longer speaks. The ledger is written as plain status strings, the shape the web UI already reads, so no reader breaks.
4.4 One audible-pause value
assembly/export.py hard-coded gap_ms=4000 in one path and defaulted to 1000 in the other,
while the AR package used SILENCE_MS = 2000 for lead and trail — three drifting values for
one concept. The m4b inter-chapter gap now imports SILENCE_MS, so all three are one
number that can only change in one place.
4.5 scripts/publish_release.py
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio"
python scripts/publish_release.py --project "..." --draft-comment --by worstauthor
python scripts/publish_release.py --project "..." --edit-comment <slug> --text "..."
python scripts/publish_release.py --project "..." --approve-comment <slug>
python scripts/publish_release.py --project "..." --publish --comment <slug> --by you
python scripts/publish_release.py --project "..." --verify pub-001-2026-10-03
python scripts/publish_release.py --project "..." --comments
python scripts/publish_release.py --project "..." --list
A later fix is --edition 2 --supersedes pub-001-2026-10-03. Never an edit.
5. Invariants
| ID | invariant | Enforced by |
|---|---|---|
| R-1 | A published record is immutable; a later change is a successor edition with supersedes |
write_publication refuses to overwrite |
| R-2 | A release pins the chapters that make the package, each by SHA-256 | build_publication, verify_publication |
| R-3 | No publication occurs while a blocker exists | publish_release.py --publish exits 2 |
| R-4 | Any post-approval transform is recorded in the release | delivery_profile + per-chapter warnings |
| R-5 | A changed artifact after publication is reported as drift, never silently accepted | verify_publication → drift |
| R-6 | The publication record is the chapter and audiobook approval, and says so | approvals.chapter_approved/audiobook_approved = true, approved_by: "this publication record" |
| R-8 | No publication without an approved release comment | require_approved(); --publish with a draft exits 2 |
| R-9 | An approved comment is frozen; a changed note is a new release | update_comment() raises on edit-after-approve |
| R-10 | Implicit voice approval covers only voices that have lines in the release | voices_used_in_release(); unused profile entries are excluded and counted |
| R-11 | One audible-pause value across delivery paths | CHAPTER_GAP_MS imported from ar_export.SILENCE_MS |
| R-7 | The approval ledger's coverage is stated, not assumed | voices_in_profile vs voices_approved |
R-6 and R-10 together are what Deal Hunter lacked: the release says, in its own words, that it is the chapter and audiobook approval, and exactly which voices that acceptance covered.
6. Relationship to the controller's model
This is the local, immutable half of what the controller calls editions + edition_artifacts:
| local field | controller entity |
|---|---|
publication_id, edition_number |
editions |
supersedes |
editions.supersedes_edition |
chapters[].chapter_wav.sha256, mp3.sha256 |
edition_artifacts (immutable member set, I-H) |
delivery_profile |
derivation on each artifact |
chapters[].source_unit_key_digest |
linkage back to source_revisions / source_units |
approvals |
the acceptance record behind a release decision |
release_comment (slug + designator + text) |
the human release note and its approval |
approvals.voices_implicitly_approved_names |
which voices this release accepted, and why |
published_at / published_by |
released_at / actor |
The record is shaped so ingest is a copy, not a transformation. Nothing the controller would need is expressed in a local-only dialect.
7. Known gaps, stated rather than hidden
- The m4b path stitches differently from the approved chapter.
export_m4b._stitch_chapterhard-codes 500 ms / 0 ms silence;ChapterBuilderusesAudioStitcher()defaults (500/2000/4000) andAudioConfigcarries its own values. Until the m4b path consumes the same stitched chapter artifact the reviewer approved, a chapter approval still does not cover the m4b. This is the last real hole in R-2 and it is not yet closed. Proposed fix: makeexport_m4bconsumech{N}_full.wavdirectly instead of re-stitching from utterances. - No release-level approval in the web UI. The flag exists as a CLI and a file. A reviewer cannot yet press "publish" in the review interface, which is where they will expect it.
projects.statusis never advanced toreleased. All four live projects sit atparsed/analyzed. The publication record is currently the only release signal.- Not idempotent against a rebuild. Re-running the exporter overwrites
ar_package.json; the publication record keeps the old hashes, so verification correctly reports drift — but there is no automatic "this needs a new edition" workflow yet. - Scope. Only the AR MP3 package path is covered. A single-workbook
m4bbuilt byexport_m4bis not yet represented, for the reason in gap 1.
8. What to do next
- Close gap 1 first: make the m4b path consume the approved
ch{N}_full.wav. Until then the release record for an m4b would be pinning the wrong bytes, and it is better not to offer one. - Put the publish flag behind the review UI, gated on
verify_publicationreturningok. - Set
projects.status = 'released'from the same act that writes the record, so the two cannot disagree. - Send this shape to the controller as the ingest payload for
editions/edition_artifacts.