Files
UmbraAudio/specs/22-releases-and-the-publish-flag.md
B.D. Murphy 9f3a0a4b18 Release comment as a release step; releasing grants implicit voice approval
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.
2026-10-03 06:49:42 -07:00

15 KiB
Raw Permalink Blame History

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:

  1. 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.
  2. The publish flag is the release record. Writing it is the approval — because no other artifact-level audiobook approval exists.
  3. The pinned set is the chapters that make the m4b, not the loose utterances and not the pre-encode intermediate.
  4. 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.
  5. A change after publication is a successor, never an edit. A published record is immutable; a fix produces edition n+1 with supersedes pointing 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.

  1. Releasing approves the voices a listener hears. The rest of the cast is implicitly approved by the release and is tagged. §4.3.
  2. 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.
  3. 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 every source_unit_key in 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.json is 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 #1 and #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. --publish with 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

  1. The m4b path stitches differently from the approved chapter. export_m4b._stitch_chapter hard-codes 500 ms / 0 ms silence; ChapterBuilder uses AudioStitcher() defaults (500/2000/4000) and AudioConfig carries 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: make export_m4b consume ch{N}_full.wav directly instead of re-stitching from utterances.
  2. 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.
  3. projects.status is never advanced to released. All four live projects sit at parsed/analyzed. The publication record is currently the only release signal.
  4. 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.
  5. Scope. Only the AR MP3 package path is covered. A single-workbook m4b built by export_m4b is 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_publication returning ok.
  • 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.