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.
This commit is contained in:
B.D. Murphy
2026-10-03 06:49:42 -07:00
parent ed3e249d0b
commit 9f3a0a4b18
8 changed files with 1075 additions and 107 deletions

View File

@@ -171,6 +171,52 @@ Each pass has quality gates. Quality is the primary concern; generation speed is
## Current Session Summary
### Session: Release comments as a release step + implicit voice approval (spec 22 §1.1)
- **Two corrections from the operator, both of which I had 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 the ones with no
record were waiting to be tagged. `chapter_approved`/`audiobook_approved` now read
**true**, `approved_by: "this publication record"`, and publishing **grants** implicit
approval to every voice a listener hears.
2. The m4b inter-chapter gap should be the **same 2 s** as the lead/trail padding.
- **The precise rule, and its boundary**: implicit approval covers voices that **have lines
in the released chapters**, computed from the DB (`voices_used_in_release`), not every entry
in `speakers.json`. A character who never speaks is **not** covered — releasing a book does
not approve a voice it never uses. Measured on Deal Hunter ed. 1: **24 explicitly approved,
29 implicitly approved, 10 in-profile voices with no lines (not covered)**. 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 **provenance in the immutable publication record**, not in the
mutable ledger, so it cannot be edited away.
- **New `umbraaudio/release_comments.py`** — the release 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** — exactly so a same-day correction is unambiguous about which
was last. Two comments in the same *second* still get distinct slugs (sequence allocated by
scanning the dir). First line = summary, rest = body, like a commit message. **An approved
comment is frozen**: editing one needs a new comment and therefore a new publication.
`require_approved()` blocks `--publish` (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 already exists**
(`release-20261003-133508-01`, status DRAFT) summarising the last m4b build: 32 chapters,
the 24/29/10 voice split, 32/32 loudness and peak gates green, and the 32-chapter
post-approval padding note.
- **One audible-pause value**: `assembly/export.py` had `gap_ms=4000` in one path and
`chapter_gap_ms` defaulting to `1000` in the other, while `ar_export.SILENCE_MS = 2000`
governed lead/trail — three drifting values for one concept. The m4b gap now **imports**
`SILENCE_MS` as `CHAPTER_GAP_MS`, so all three are one number.
- **Two of my own test premises were wrong and the implementation was right** — recorded
because the tests were the weaker artefact: (a) two comments in the same second do **not**
collide (the per-date sequence prevents it), so the test asserts distinct slugs instead of
an error; (b) `parse_comment` strips leading blank lines, so a padded note cannot silently
become a blank summary.
- Tests: `tests/test_release_comments.py` (26) + `tests/test_release.py` extended to cover the
three-way approval split. Full suite **538 passed / 8 failed** — the same 8 pre-existing/
parallel failures. ruff clean on all new files.
- **Nothing was published.** The Deal Hunter comment is a draft awaiting the operator's
review and approval; `voice_status.json` is untouched because publishing never ran.
### Session: The publish flag, pinned release sets, and the Deal Hunter lesson (spec 22)
- **Trigger**: operator clarification of the release model — the system stores current +
previous state like git, there must be an **explicit publish flag** the user sets, there

View File

@@ -1,27 +1,32 @@
#!/usr/bin/env python3
"""Publish a release: pin what shipped, then prove it (spec 22).
"""Publish a release: author a comment, approve it, then pin what shipped (spec 22).
An approval exists for a speaker voice. There is no approval for a chapter and none for the
audiobook, and Deal Hunter already paid for that gap: 2 s of dead sound was added to the
start and end of every chapter *after* approval, so the approved artifact and the published
artifact were different files.
A release is a pinned set of artifacts plus an authored comment. The comment is a step, not
a caption: it is authored, read, changed if wrong, and approved before anything can ship.
Its designator is date + time + a per-date sequence (``2026-10-03 #2 14:32:05``) so two
releases on one day are never ambiguous about which came last.
This script makes the release explicit.
Releasing also grants **implicit approval** of every voice a listener hears in the audiobook.
Those voices are tagged, with the provenance kept in the immutable publication record.
# What would ship, and what is missing? Read-only. Always do this first.
# 1. see what would ship (read-only)
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio"
# Write the immutable publication record (refuses to overwrite an existing one).
python scripts/publish_release.py --project "..." --publish --by worstauthor
# 2. author a comment from the measured build (a DRAFT; read it)
python scripts/publish_release.py --project "..." --draft-comment --by worstauthor
# Re-verify a published record against the filesystem.
python scripts/publish_release.py --project "..." --verify pub-001-2026-09-09
# 3. change it, as many times as needed
python scripts/publish_release.py --project "..." --edit-comment <slug> --text "..."
# List published editions.
python scripts/publish_release.py --project "..." --list
# 4. approve it
python scripts/publish_release.py --project "..." --approve-comment <slug>
A change after publication is a NEW edition (``--edition 2 --supersedes pub-...``), never an
edit of a released record.
# 5. publish - pins hashes, tags implicitly approved voices
python scripts/publish_release.py --project "..." --publish --comment <slug> --by you
# later: a fix is a NEW edition, never an edit
python scripts/publish_release.py --project "..." --draft-comment --edition 2 \
--supersedes pub-001-2026-09-09
"""
from __future__ import annotations
@@ -35,11 +40,21 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
from umbraaudio.release import (
build_publication,
grant_implicit_voice_approval,
list_publications,
publication_path,
verify_publication,
write_publication,
)
from umbraaudio.release_comments import (
CommentError,
create_comment,
draft_comment_text,
list_comments,
load_comment,
require_approved,
update_comment,
)
def _git_commit() -> str | None:
@@ -48,44 +63,49 @@ def _git_commit() -> str | None:
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=10,
check=False,
capture_output=True, text=True, timeout=10, check=False,
)
return out.stdout.strip() or None
except (OSError, subprocess.SubprocessError):
return None
def _print_comment(comment) -> None: # noqa: ANN001 - ReleaseComment, avoids an import cycle
print("RELEASE COMMENT")
print(comment.render())
def _print_summary(record: dict) -> None:
profile = record["delivery_profile"]
approvals = record["approvals"]
comment = record.get("release_comment")
print(f"publication_id {record['publication_id']}")
print(f"work / edition {record['work']} #{record['edition_number']}"
+ (f" (supersedes {record['supersedes']})" if record.get("supersedes") else ""))
print(f"release comment {comment['designator']} [{comment['status']}]"
if comment else "release comment NONE - a release needs an approved comment")
print(f"chapters pinned {len(record['chapters'])}")
pkg = record["package"]
print(f"package {pkg['object_key']} {pkg['bytes']} bytes sha256="
f"{(pkg['sha256'] or '')[:16]}…")
print("delivery profile "
f"{profile['sample_rate']} Hz / {profile['channels']} ch"
print(f"package {pkg['object_key']} {pkg['bytes']} bytes "
f"sha256={(pkg['sha256'] or '')[:16]}…")
print(f"delivery profile {profile['sample_rate']} Hz / {profile['channels']} ch"
+ (f" / {profile['bit_rate_kbps']} kbps" if profile.get("bit_rate_kbps") else "")
+ f" silence +{profile['lead_silence_s']}s lead, +{profile['trail_silence_s']}s trail"
+ f" +{profile['lead_silence_s']}s lead, +{profile['trail_silence_s']}s trail"
f" rms {profile['target_rms_db']} dB")
approvals = record["approvals"]
print(f"approvals {approvals['voices_approved']} of "
f"{approvals['voices_in_profile']} speakers have an approval record; "
f"{approvals['voices_not_approved']} do not")
print(" chapter approved: no | audiobook approved: no "
"-> this record IS the release approval")
print(f"approvals {approvals['voices_explicitly_approved']} voices explicitly "
f"approved; {approvals['voices_implicitly_approved']} approved implicitly by this "
f"release; {approvals['voices_in_profile_without_lines']} in the profile do not "
"speak and are NOT covered")
print(" chapter approved: yes | audiobook approved: yes "
"-> granted by this record")
warned = [c for c in record["chapters"] if c["warnings"]]
print(f"\npost-approval deltas detected on {len(warned)}/{len(record['chapters'])} chapters")
for chapter in warned[:5]:
print(f"\npost-approval deltas on {len(warned)}/{len(record['chapters'])} chapters")
for chapter in warned[:3]:
for warning in chapter["warnings"]:
print(f" ch{chapter['chapter_id']:>3} {warning}")
if len(warned) > 5:
print(f" ... and {len(warned) - 5} more")
if len(warned) > 3:
print(f" ... and {len(warned) - 3} more")
if record["blockers"]:
print(f"\nBLOCKERS ({len(record['blockers'])}) - not publishable:")
@@ -97,13 +117,22 @@ def _print_summary(record: dict) -> None:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", default=None, help="project name or path")
parser.add_argument("--publish", action="store_true", help="write the immutable record")
parser.add_argument("--verify", metavar="PUB_ID", default=None, help="verify a record")
parser.add_argument("--list", action="store_true", help="list published editions")
parser.add_argument("--project", default=None)
parser.add_argument("--publish", action="store_true")
parser.add_argument("--verify", metavar="PUB_ID", default=None)
parser.add_argument("--list", action="store_true")
parser.add_argument("--comments", action="store_true", help="list release comments")
parser.add_argument("--show-comment", metavar="SLUG", default=None)
parser.add_argument("--draft-comment", action="store_true",
help="author a comment from the measured build (a DRAFT)")
parser.add_argument("--edit-comment", metavar="SLUG", default=None)
parser.add_argument("--approve-comment", metavar="SLUG", default=None)
parser.add_argument("--comment", metavar="SLUG", default=None,
help="the approved comment to publish with")
parser.add_argument("--text", default=None, help="comment text (first line = summary)")
parser.add_argument("--edition", type=int, default=1)
parser.add_argument("--supersedes", default=None, help="publication_id this replaces")
parser.add_argument("--by", default="operator", help="who is publishing")
parser.add_argument("--supersedes", default=None)
parser.add_argument("--by", default="operator")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
@@ -125,6 +154,79 @@ def main() -> int:
+ (f" supersedes {r['supersedes']}" if r["supersedes"] else ""))
return 0
if args.comments:
rows = list_comments(data_dir)
if args.json:
print(json.dumps(rows, indent=2))
elif not rows:
print(f"no release comments for {work}")
else:
print(f"release comments for {work}")
for r in rows:
print(f" {r['designator']} [{r['status']:>8}] {r['slug']}")
print(f" {r['summary']}")
return 0
if args.show_comment:
comment = load_comment(data_dir, args.show_comment)
if comment is None:
print(f"no such comment: {args.show_comment}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(comment.as_dict(), indent=2))
else:
_print_comment(comment)
return 0
if args.approve_comment:
try:
comment = update_comment(
data_dir, args.approve_comment, text=args.text, status="approved"
)
except CommentError as exc:
print(str(exc), file=sys.stderr)
return 2
print("approved:")
_print_comment(comment)
print(f"\npublish with:\n scripts/publish_release.py --project {cfg.project_dir.name} "
f"--publish --comment {comment.slug} --by {args.by}")
return 0
if args.edit_comment:
if not args.text:
print("--edit-comment needs --text", file=sys.stderr)
return 1
try:
comment = update_comment(data_dir, args.edit_comment, text=args.text)
except CommentError as exc:
print(str(exc), file=sys.stderr)
return 2
print("updated:")
_print_comment(comment)
return 0
if args.draft_comment:
record = build_publication(
data_dir, work, edition_number=args.edition, supersedes=args.supersedes
)
text = draft_comment_text(record, args.by)
try:
comment = create_comment(
data_dir, text, author=args.by, facts={
"chapters": len(record["chapters"]),
"package_sha256": (record["package"] or {}).get("sha256"),
"built_at": (record["package"] or {}).get("built_at"),
"voices_implicitly_approved":
record["approvals"]["voices_implicitly_approved"],
},
)
except CommentError as exc:
print(str(exc), file=sys.stderr)
return 2
print("DRAFTED from the measured build. Read it, change it if it is wrong, then approve.")
_print_comment(comment)
return 0
if args.verify:
path = publication_path(data_dir, args.verify)
if not path.exists():
@@ -144,6 +246,14 @@ def main() -> int:
print(f" ... and {len(result['warnings']) - 10} more notes")
return 0 if result["ok"] else 2
comment = None
if args.comment:
try:
comment = require_approved(data_dir, args.comment)
except CommentError as exc:
print(f"refusing to publish: {exc}", file=sys.stderr)
return 2
record = build_publication(
data_dir,
work,
@@ -151,6 +261,7 @@ def main() -> int:
published_by=args.by,
git_commit=_git_commit(),
supersedes=args.supersedes,
comment=comment.as_dict() if comment else None,
)
if not args.publish:
@@ -158,18 +269,30 @@ def main() -> int:
print(json.dumps(record, indent=2, sort_keys=True))
else:
_print_summary(record)
print("\nDRY RUN - nothing written. Re-run with --publish to record it.")
print("\nDRY RUN - nothing written.")
if not comment:
print("next: --draft-comment, read it, then --approve-comment <slug>")
return 0 if record["publishable"] else 2
if not record["publishable"]:
print("refusing to publish: blockers present (see above)", file=sys.stderr)
print("refusing to publish: blockers present", file=sys.stderr)
_print_summary(record)
return 2
path = write_publication(data_dir, record)
granted = grant_implicit_voice_approval(data_dir, record)
print(f"published -> {path}")
print(f" this record is immutable. A later fix is edition "
f"{args.edition + 1} with --supersedes {record['publication_id']}.")
if comment:
print(f" comment {comment.designator} ({comment.slug})")
print(f" pinned {len(record['chapters'])} chapters + package by sha256")
if granted["updated"]:
print(f" implicit approval granted to {len(granted['updated'])} voice(s) "
f"heard in this release: {', '.join(granted['updated'][:8])}"
+ (" ..." if len(granted["updated"]) > 8 else ""))
print(f" basis: {granted['basis']}; provenance kept in this record")
print(f" next release edition {args.edition + 1} with "
f"--supersedes {record['publication_id']} - this record is immutable.")
return 0

View File

@@ -35,6 +35,24 @@ Five consequences, each implemented:
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.
6. **Releasing approves the voices a listener hears.** The rest of the cast is *implicitly
approved by the release* and is tagged. §4.3.
7. **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.
8. **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`,
@@ -122,19 +140,76 @@ Two fields carry most of the weight and are worth calling out:
"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 `scripts/publish_release.py`
### 4.2 Release comments — `umbraaudio/release_comments.py`
```bash
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio" # dry run
python scripts/publish_release.py --project "..." --publish --by worstauthor # write
python scripts/publish_release.py --project "..." --verify pub-001-2026-09-09 # re-check
python scripts/publish_release.py --project "..." --list # editions
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
```
Dry run is the default and prints the pinned set, the delivery profile, the approval gap, and
**every post-approval delta it found**. `--publish` refuses to run while `blockers` is
non-empty. A second `--publish` against the same id raises `FileExistsError` — immutability
is enforced by the writer, not by convention.
- **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`
```bash
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
@@ -145,11 +220,15 @@ is enforced by the writer, not by convention.
| **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** | An approval is never inferred from the absence of a complaint | `approvals.chapter_approved = false` is recorded explicitly |
| **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 is the one that would have prevented Deal Hunter: the release says, in its own words,
that no chapter and no audiobook was 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
@@ -163,6 +242,8 @@ This is the local, immutable half of what the controller calls `editions` + `edi
| `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

View File

@@ -32,7 +32,14 @@ def _wav(path: Path, seconds: float, rate: int = 24000) -> Path:
return path
def _project(tmp_path: Path, tracks: list[dict[str, Any]], fmt: dict | None = None) -> Path:
def _project(
tmp_path: Path,
tracks: list[dict[str, Any]],
fmt: dict | None = None,
speakers: list[str] | None = None,
ledger: dict[str, str] | None = None,
dialogue: list[tuple[str, str]] | None = None,
) -> Path:
"""Build a minimal project tree with an exporter manifest."""
data_dir = tmp_path / ".umbra"
package_dir = data_dir / "export" / "ar_package"
@@ -55,11 +62,34 @@ def _project(tmp_path: Path, tracks: list[dict[str, Any]], fmt: dict | None = No
"tracks": tracks,
}
(data_dir / "export" / "ar_package.json").write_text(json.dumps(manifest))
names = speakers if speakers is not None else ["Hallr", "Xylia", "Unreviewed"]
(data_dir / "speakers.json").write_text(
json.dumps({"speakers": [{"primary_name": "Hallr"}, {"primary_name": "Xylia"},
{"primary_name": "Unreviewed"}]})
json.dumps({"speakers": [{"primary_name": n} for n in names]})
)
(data_dir / "voice_status.json").write_text(json.dumps({"Hallr": "approved"}))
entries = ledger if ledger is not None else {"Hallr": "approved"}
(data_dir / "voice_status.json").write_text(json.dumps(entries))
# A real project DB so voice usage can be measured, not assumed.
if dialogue is not None:
chapter_id = tracks[0].get("chapter_id", 1)
import sqlite3
con = sqlite3.connect(str(data_dir / "project.db"))
con.executescript(
"CREATE TABLE chapters (id INTEGER PRIMARY KEY, source_text TEXT);"
"CREATE TABLE utterances (id INTEGER PRIMARY KEY, chapter_id INTEGER, "
"utterance_index INTEGER, text TEXT, type TEXT DEFAULT 'narration', "
"speaker_name TEXT);"
)
con.execute("INSERT INTO chapters (id, source_text) VALUES (?,?)", (chapter_id, "Body."))
for i, (speaker, text) in enumerate(dialogue):
con.execute(
"INSERT INTO utterances (id, chapter_id, utterance_index, text, type, "
"speaker_name) VALUES (?,?,?,?,'dialogue',?)",
(i + 1, chapter_id, i, text, speaker),
)
con.commit()
con.close()
return data_dir
@@ -103,27 +133,42 @@ class TestBuildPublication:
)
assert record["package"]["track_count"] == 1
def test_approval_gap_is_reported_not_glossed(self, tmp_path: Path) -> None:
"""voice_status.json only holds speakers someone touched. Saying '3 approved'
would claim an approval that was never given."""
data_dir = _project(tmp_path, [
{"seq": 1, "title": "One", "file": "01.mp3", "source": "ch1_full.wav",
"duration_s": 2.0, "chapter_seconds": 2.0},
])
def test_approval_is_split_explicit_implicit_and_unused(self, tmp_path: Path) -> None:
"""The three categories must never be conflated.
Releasing approves every voice a listener HEARS. It does not approve a profile
entry the release never uses.
"""
data_dir = _project(
tmp_path,
[{"seq": 1, "title": "One", "file": "01.mp3", "source": "ch1_full.wav",
"duration_s": 2.0, "chapter_seconds": 2.0}],
speakers=["Hallr", "Xylia", "NeverSpeaks"],
ledger={"Hallr": "approved"},
dialogue=[("Hallr", "Hello."), ("Xylia", "Goodbye.")],
)
approvals = build_publication(data_dir, "Book")["approvals"]
assert approvals["voices_in_profile"] == 3
assert approvals["voices_approved"] == 1
assert approvals["voices_not_approved"] == 2
assert "Unreviewed" in approvals["voices_not_approved_names"]
assert approvals["voices_explicitly_approved"] == 1
# Xylia speaks but was never explicitly reviewed -> implicit
assert approvals["voices_implicitly_approved"] == 1
assert approvals["voices_implicitly_approved_names"] == ["Xylia"]
# NeverSpeaks is in the profile but has no lines -> not covered at all
assert approvals["voices_in_profile_without_lines"] == 1
assert approvals["voices_without_lines_names"] == ["NeverSpeaks"]
assert approvals["implicit_approval_basis"] == "the act of releasing this audiobook"
def test_records_that_chapter_and_audiobook_are_unapproved(self, tmp_path: Path) -> None:
def test_publication_record_is_the_chapter_and_audiobook_approval(
self, tmp_path: Path
) -> None:
data_dir = _project(tmp_path, [
{"seq": 1, "title": "One", "file": "01.mp3", "source": "ch1_full.wav",
"duration_s": 2.0, "chapter_seconds": 2.0},
])
approvals = build_publication(data_dir, "Book")["approvals"]
assert approvals["chapter_approved"] is False
assert approvals["audiobook_approved"] is False
assert approvals["chapter_approved"] is True
assert approvals["audiobook_approved"] is True
assert approvals["approved_by"] == "this publication record"
class TestPostApprovalDelta:

View File

@@ -0,0 +1,212 @@
"""Tests for release comments - the explicit step in a release (spec 22).
The comment is a gate, and its designator has to make two releases on the same day
unambiguous. Those are the two properties worth pinning.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from pathlib import Path
from umbraaudio.release_comments import (
STATUS_APPROVED,
STATUS_DRAFT,
CommentError,
build_designator,
build_slug,
comment_path,
create_comment,
draft_comment_text,
list_comments,
load_comment,
next_sequence,
parse_comment,
require_approved,
update_comment,
)
T0 = datetime(2026, 10, 3, 14, 32, 5, tzinfo=UTC)
class TestDesignator:
def test_slug_carries_date_time_and_sequence(self) -> None:
assert build_slug(T0, 2) == "release-20261003-143205-02"
def test_designator_is_readable(self) -> None:
assert build_designator(T0, 2) == "2026-10-03 #2 14:32:05"
def test_sequence_is_per_date_not_global(self, tmp_path: Path) -> None:
create_comment(tmp_path, "First", now=T0)
create_comment(tmp_path, "Second", now=T0 + timedelta(seconds=30))
assert next_sequence(tmp_path, T0) == 3
# A new date starts at 1 again, because the date is part of the handle.
assert next_sequence(tmp_path, T0 + timedelta(days=1)) == 1
def test_same_day_sequence_disambiguates_two_releases(self, tmp_path: Path) -> None:
a = create_comment(tmp_path, "First attempt", now=T0)
b = create_comment(tmp_path, "Corrected attempt", now=T0 + timedelta(hours=1))
assert a.sequence == 1 and b.sequence == 2
assert a.date == b.date
assert a.slug != b.slug
assert "#2" in b.designator
def test_two_comments_in_the_same_second_still_get_distinct_slugs(
self, tmp_path: Path
) -> None:
"""The sequence is what makes this safe, not a timestamp collision check."""
a = create_comment(tmp_path, "One", now=T0)
b = create_comment(tmp_path, "Two", now=T0)
assert a.slug != b.slug
assert (a.sequence, b.sequence) == (1, 2)
assert load_comment(tmp_path, a.slug).summary == "One"
assert load_comment(tmp_path, b.slug).summary == "Two"
class TestParseComment:
def test_first_line_is_the_summary(self) -> None:
summary, body = parse_comment("Fix the padding\n\n2s lead and trail.")
assert summary == "Fix the padding"
assert body == "2s lead and trail."
def test_body_only_comment(self) -> None:
summary, body = parse_comment("Just a summary")
assert body == ""
def test_empty_comment_is_refused(self) -> None:
with pytest.raises(CommentError, match="at least a summary"):
parse_comment(" \n \n")
def test_leading_blank_lines_are_ignored(self) -> None:
"""Padding a note must not silently become the summary."""
summary, body = parse_comment("\n\nShipped the fix\n\nDetail.")
assert summary == "Shipped the fix"
assert body == "Detail."
class TestLifecycle:
def test_new_comment_is_a_draft(self, tmp_path: Path) -> None:
comment = create_comment(tmp_path, "Ship it", now=T0)
assert comment.status == STATUS_DRAFT
assert comment.approved_at is None
def test_draft_can_be_edited(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "Wrong summary", now=T0)
edited = update_comment(tmp_path, created.slug, text="Right summary\nwith detail")
assert edited.summary == "Right summary"
assert edited.body == "with detail"
def test_approved_comment_is_frozen(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "Original", now=T0)
update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
with pytest.raises(CommentError, match="frozen"):
update_comment(tmp_path, created.slug, text="Changed my mind")
# ...and the stored text is genuinely unchanged.
assert load_comment(tmp_path, created.slug).summary == "Original"
def test_approve_records_the_time(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "Ready", now=T0)
approved = update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
assert approved.status == STATUS_APPROVED
assert approved.approved_at is not None
def test_cannot_change_text_while_approving(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "As drafted", now=T0)
with pytest.raises(CommentError, match="Edit the draft first"):
update_comment(tmp_path, created.slug, text="Different", status=STATUS_APPROVED)
def test_publishing_requires_an_approved_comment(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "Draft only", now=T0)
with pytest.raises(CommentError, match="DRAFT"):
require_approved(tmp_path, created.slug)
def test_publishing_requires_a_comment_to_exist(self, tmp_path: Path) -> None:
with pytest.raises(CommentError, match="no release comment"):
require_approved(tmp_path, "release-20261003-000000-01")
def test_approved_comment_satisfies_the_gate(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "Good to go", now=T0)
update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
assert require_approved(tmp_path, created.slug).slug == created.slug
def test_approved_at_creation_is_immediately_publishable(self, tmp_path: Path) -> None:
created = create_comment(tmp_path, "Pre-approved", now=T0, status=STATUS_APPROVED)
assert require_approved(tmp_path, created.slug).status == STATUS_APPROVED
class TestStorage:
def test_round_trips_through_disk(self, tmp_path: Path) -> None:
created = create_comment(
tmp_path, "Summary line\n\nBody text.", author="worstauthor", now=T0,
facts={"chapters": 32},
)
loaded = load_comment(tmp_path, created.slug)
assert loaded.summary == "Summary line"
assert loaded.body == "Body text."
assert loaded.facts["chapters"] == 32
assert loaded.author == "worstauthor"
def test_listing_is_newest_first(self, tmp_path: Path) -> None:
a = create_comment(tmp_path, "First", now=T0)
b = create_comment(tmp_path, "Second", now=T0 + timedelta(minutes=5))
rows = list_comments(tmp_path)
assert [r["slug"] for r in rows] == [b.slug, a.slug]
def test_missing_comment_is_none_not_an_error(self, tmp_path: Path) -> None:
assert load_comment(tmp_path, "release-19700101-000000-01") is None
def test_no_tmp_files_left_behind(self, tmp_path: Path) -> None:
create_comment(tmp_path, "Clean write", now=T0)
assert list(comment_path(tmp_path, "release-20261003-143205-01").parent.glob("*.tmp")) == []
def test_listing_empty_dir(self, tmp_path: Path) -> None:
assert list_comments(tmp_path) == []
class TestDraftFromBuild:
def _record(self) -> dict:
return {
"work": "Deal Hunter",
"edition_number": 1,
"package": {
"object_key": "export/Deal_Hunter.m4b",
"bytes": 325875309,
"built_at": "2026-09-09T11:21:11+00:00",
"sha256": "abc",
},
"delivery_profile": {
"sample_rate": 44100, "channels": 1, "bit_rate_kbps": 192,
"lead_silence_s": 2.0, "trail_silence_s": 2.0, "target_rms_db": -20.5,
},
"approvals": {
"voices_explicitly_approved": 24,
"voices_implicitly_approved": 29,
"voices_in_profile_without_lines": 10,
},
"chapters": [
{"mp3": {"loudness_ok": True, "peak_ok": True}, "warnings": ["4.0s longer"]},
{"mp3": {"loudness_ok": True, "peak_ok": True}, "warnings": []},
],
}
def test_comment_states_the_measured_facts(self) -> None:
text = draft_comment_text(self._record(), "worstauthor")
assert "Deal Hunter edition 1" in text
assert "2 chapters pinned" in text
assert "44100 Hz" in text
assert "+2.0s lead" in text
assert "24 explicitly approved" in text
assert "29 approved implicitly" in text
def test_comment_names_the_post_approval_delta(self) -> None:
text = draft_comment_text(self._record(), "worstauthor")
assert "1 chapters shipped longer or resampled" in text
def test_comment_is_first_line_summary(self) -> None:
summary, _ = parse_comment(draft_comment_text(self._record(), "x"))
assert "Deal Hunter" in summary

View File

@@ -8,6 +8,14 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Inter-chapter gap inside the m4b. Kept equal to the AR package's leading/trailing silence
# (ar_export.SILENCE_MS) so every delivery uses one audible-pause value instead of three
# drifting ones. Imported rather than re-declared so the two cannot diverge again.
try:
from umbraaudio.assembly.ar_export import SILENCE_MS as CHAPTER_GAP_MS
except Exception: # pragma: no cover - ar_export pulls numpy/soundfile
CHAPTER_GAP_MS = 2000
class AudiobookExporter:
def export_m4b(
@@ -38,7 +46,7 @@ class AudiobookExporter:
concat_wav = chapter_wavs[0]
else:
concat_wav = tmp_dir / "concatenated.wav"
self._concat_wavs_with_silence(chapter_wavs, concat_wav, gap_ms=4000)
self._concat_wavs_with_silence(chapter_wavs, concat_wav, gap_ms=CHAPTER_GAP_MS)
m4b_no_meta = tmp_dir / "no_meta.m4b"
self._encode_aac_m4b(concat_wav, m4b_no_meta)
@@ -136,7 +144,9 @@ class AudiobookExporter:
# --- 2) Concatenate chapters with a small gap --------------------------
concat_wav = tmp_dir / "concatenated.wav"
self._concat_wavs_with_silence(staged, concat_wav, gap_ms=int(meta.get("chapter_gap_ms", 1000)))
self._concat_wavs_with_silence(
staged, concat_wav, gap_ms=int(meta.get("chapter_gap_ms", CHAPTER_GAP_MS))
)
# --- 3) Encode AAC M4B ------------------------------------------------
m4b_no_meta = tmp_dir / "no_meta.m4b"

View File

@@ -252,10 +252,12 @@ def build_publication(
git_commit: str | None = None,
supersedes: str | None = None,
now: str | None = None,
comment: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Capture the pinned set, the approvals in force, and the delivery profile.
Read-only with respect to the project: hashes files, writes nothing.
Read-only with respect to the project: hashes files, writes nothing. ``comment`` is the
approved release comment to embed; the CLI refuses to publish without one.
"""
data_dir = Path(data_dir)
export_dir = data_dir / "export"
@@ -336,7 +338,9 @@ def build_publication(
)
voice_status = _read_json(data_dir / "voice_status.json")
approvals = _approval_block(voice_status, data_dir)
chapter_ids = sorted({c.chapter_id for c in chapters if c.chapter_id >= 0})
used_voices = voices_used_in_release(data_dir, chapter_ids)
approvals = _approval_block(voice_status, data_dir, used_voices)
package_file = export_dir / f"{package.get('base_name', work)}.m4b"
if not package_file.exists():
@@ -364,59 +368,162 @@ def build_publication(
"built_at": package.get("built_at"),
},
"approvals": approvals,
"release_comment": comment,
"chapters": [c.as_dict() for c in chapters],
"blockers": blockers,
"publishable": not blockers,
}
def _approval_block(voice_status: dict[str, Any], data_dir: Path) -> dict[str, Any]:
"""The per-item approvals that were in force, pinned by digest so they are auditable.
def voices_used_in_release(data_dir: Path, chapter_ids: list[int]) -> set[str]:
"""Canonical speaker names that actually have lines in the released chapters.
``voice_status.json`` is an *approval ledger*: it only holds speakers someone explicitly
touched, so it is smaller than ``speakers.json``. Reporting its size as "voices approved"
would imply an approval that was never given, so the two counts are reported separately
and the gap is named.
Implicit approval covers the voices a listener HEARS in this release, not every entry
in ``speakers.json``. A character who never speaks in the released chapters is not
covered by releasing it, and pretending otherwise would overstate the approval.
"""
raw = voice_status.get("speakers", voice_status) if isinstance(voice_status, dict) else {}
entries: dict[str, Any] = raw if isinstance(raw, dict) else {}
statuses = {
name: (value.get("status") if isinstance(value, dict) else value)
for name, value in entries.items()
}
approved = sorted(n for n, s in statuses.items() if str(s).lower() == "approved")
import sqlite3
profiles = _read_json(data_dir / "speakers.json").get("speakers", []) or []
in_profile = {
db_path = Path(data_dir) / "project.db"
if not db_path.exists() or not chapter_ids:
return set()
profiles = _read_json(Path(data_dir) / "speakers.json").get("speakers", []) or []
alias: dict[str, str] = {}
for profile in profiles:
if not isinstance(profile, dict):
continue
primary = str(profile.get("primary_name", ""))
if primary:
alias[primary.casefold()] = primary
for extra in profile.get("aliases") or []:
if extra:
alias[str(extra).casefold()] = primary
placeholders = ",".join("?" * len(chapter_ids))
con = sqlite3.connect(f"file:{db_path.resolve()}?mode=ro", uri=True)
try:
rows = con.execute(
f"SELECT DISTINCT speaker_name FROM utterances "
f"WHERE lower(type)='dialogue' AND chapter_id IN ({placeholders}) "
f"AND speaker_name IS NOT NULL",
chapter_ids,
).fetchall()
finally:
con.close()
used: set[str] = set()
for (name,) in rows:
clean = str(name).strip()
if not clean or clean.casefold() in ("narrator", "the narrator"):
continue
used.add(alias.get(clean.casefold(), clean))
return used
def _profile_names(data_dir: Path) -> set[str]:
profiles = _read_json(Path(data_dir) / "speakers.json").get("speakers", []) or []
return {
str(p.get("primary_name", ""))
for p in profiles
if isinstance(p, dict) and p.get("primary_name")
}
# A speaker counts as approved only if the ledger says so by primary name.
unapproved = sorted(in_profile - set(approved))
orphans = sorted(set(approved) - in_profile)
def _approval_block(voice_status: dict[str, Any], data_dir: Path, used: set[str]) -> dict[str, Any]:
"""Record the approvals in force, separating what was explicit from what the release grants.
``voice_status.json`` is an *approval ledger*: it only holds voices someone explicitly
reviewed. The rest of the cast is approved **implicitly by the act of releasing the
audiobook** - releasing the book means accepting every voice a listener hears in it.
This block makes both visible, and keeps "unused" separate from "implicitly approved":
a profile entry that never speaks is not covered by the release.
"""
raw = voice_status.get("speakers", voice_status) if isinstance(voice_status, dict) else {}
entries: dict[str, Any] = raw if isinstance(raw, dict) else {}
statuses = {
str(name): str(value.get("status") if isinstance(value, dict) else value)
for name, value in entries.items()
}
explicitly = sorted(n for n, s in statuses.items() if s.casefold() == "approved")
in_profile = _profile_names(data_dir)
used_in_profile = used & in_profile
implicitly = sorted(n for n in used_in_profile if n not in explicitly)
unused = sorted(in_profile - used_in_profile)
unprofiled_used = sorted(used - in_profile)
return {
"voices_in_profile": len(in_profile),
"voices_with_approval_record": len(approved),
"voices_approved": len(approved),
"voices_not_approved": len(unapproved),
"voices_not_approved_names": unapproved,
"approval_ledger_entries_without_profile": orphans,
"voices_explicitly_approved": len(explicitly),
"voices_explicitly_approved_names": explicitly,
"voices_explicitly_approved_and_used": len(set(explicitly) & used_in_profile),
"voices_used_in_release": len(used_in_profile),
"voices_implicitly_approved": len(implicitly),
"voices_implicitly_approved_names": implicitly,
"implicit_approval_basis": "the act of releasing this audiobook",
"voices_in_profile_without_lines": len(unused),
"voices_without_lines_names": unused,
"voices_used_without_profile": unprofiled_used,
"voice_status_digest": _digest_of_strings(
[f"{n}={s}" for n, s in sorted(statuses.items())]
),
"chapter_approved": False,
"audiobook_approved": False,
# The publication record IS the chapter and audiobook approval.
"chapter_approved": True,
"audiobook_approved": True,
"approved_by": "this publication record",
"_note": (
"voice_status.json holds per-speaker approvals only, and only for speakers "
"someone explicitly touched - it is smaller than speakers.json. There is no "
"chapter or audiobook approval in the system today; this record IS the "
"release approval."
"Voices a listener hears are approved by releasing the audiobook; those "
"without an explicit voice_status.json entry are tagged implicitly by this "
"record. Chapter and audiobook approval ARE this record - there is no "
"separate audiobook approval in the system. Profile entries that never have "
"lines are NOT covered: releasing a book does not approve a voice it never uses."
),
}
def grant_implicit_voice_approval(
data_dir: Path, record: dict[str, Any]
) -> dict[str, Any]:
"""Tag every voice used in the release as approved. Returns what it changed.
Writes ``voice_status.json`` values as plain status strings, the shape the web UI
already reads (``voice_status.get(name, "pending")``). The **provenance** - which
publication granted this, under which comment - stays in the immutable publication
record rather than in the mutable ledger, so it cannot be edited away.
"""
approvals = record.get("approvals", {}) or {}
names = list(approvals.get("voices_implicitly_approved_names", []) or [])
if not names:
return {"updated": [], "already_approved": [], "granted_by": None}
path = Path(data_dir) / "voice_status.json"
raw = _read_json(path)
if not isinstance(raw, dict):
raw = {}
if isinstance(raw.get("speakers"), dict):
raw = raw["speakers"]
updated: list[str] = []
already: list[str] = []
for name in names:
if str(raw.get(name, "")).casefold() == "approved":
already.append(name)
continue
raw[name] = "approved"
updated.append(name)
if updated:
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(tmp, path)
return {
"updated": sorted(updated),
"already_approved": sorted(already),
"granted_by": record.get("publication_id"),
"basis": approvals.get("implicit_approval_basis"),
}
def verify_publication(data_dir: Path, record: dict[str, Any]) -> dict[str, Any]:
"""Re-check a publication against the filesystem.

View File

@@ -0,0 +1,344 @@
"""Release comments: the explicit step between "ready" and "published".
A release comment is the human-authored note that accompanies a publish, in the spirit of a
git commit message: a short summary line, an optional body, an author, and a designator you
can quote later when something needs to go back to something.
Two properties make it useful rather than decorative:
1. **The designator is date + time + a per-date sequence.** ``2026-10-03 #2 14:32:05``. If a
problem is found and corrected the same day, the sequence makes it unambiguous which
attempt was last. Without it, two releases on one date are indistinguishable in a
conversation.
2. **It is a gate, not a caption.** A publication cannot be written without an *approved*
comment. An approved comment is frozen: editing one requires a new comment and therefore
a new publication, which is the same rule the publication itself follows.
Lifecycle::
draft ──approve──▶ approved ──publish──▶ frozen inside the publication
▲
└── edit freely while it is still a draft
Storage is one JSON file per comment under ``.umbra/release_comments/``, named
``release-<YYYYMMDD>-<HHMMSS>-<NN>.json``. The publication record holds a *copy* of the
approved comment, so a publication stays readable even if the comment directory is ever
tidied.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
STATUS_DRAFT = "draft"
STATUS_APPROVED = "approved"
COMMENT_DIR = "release_comments"
_SLUG_RE = re.compile(r"^release-(\d{8})-(\d{6})-(\d{2,})$")
class CommentError(RuntimeError):
"""Raised when a comment operation would violate the release rules."""
@dataclass
class ReleaseComment:
"""One authored release note."""
slug: str
designator: str
date: str
time: str
sequence: int
summary: str
body: str
author: str
authored_at: str
status: str = STATUS_DRAFT
approved_at: str | None = None
facts: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ReleaseComment:
return cls(
slug=data["slug"],
designator=data["designator"],
date=data["date"],
time=data["time"],
sequence=int(data["sequence"]),
summary=data.get("summary", ""),
body=data.get("body", ""),
author=data.get("author", "operator"),
authored_at=data.get("authored_at", ""),
status=data.get("status", STATUS_DRAFT),
approved_at=data.get("approved_at"),
facts=data.get("facts", {}) or {},
)
def render(self) -> str:
"""The block a human reads, edits and approves."""
lines = [
f" designator {self.designator}",
f" slug {self.slug}",
f" author {self.author}",
f" authored {self.authored_at}",
f" status {self.status.upper()}",
]
if self.approved_at:
lines.append(f" approved {self.approved_at}")
lines.append("")
lines.append(f" {self.summary}")
if self.body:
lines.append("")
for para in self.body.splitlines():
lines.append(f" {para}" if para else "")
return "\n".join(lines)
def comments_dir(data_dir: Path) -> Path:
return Path(data_dir) / COMMENT_DIR
def comment_path(data_dir: Path, slug: str) -> Path:
return comments_dir(data_dir) / f"{slug}.json"
def parse_comment(text: str) -> tuple[str, str]:
"""Split authored text into (summary, body).
The first non-empty line is the summary, like a commit subject. Everything after it is
the body. A first line longer than 72 characters is accepted but reported as a weak
summary rather than silently truncated, because truncating someone's note is worse than
telling them it is long.
"""
lines = text.strip().splitlines()
if not lines:
raise CommentError("a release comment needs at least a summary line")
summary = lines[0].strip()
body = "\n".join(lines[1:]).strip()
if not summary:
raise CommentError("the first line of a release comment is the summary and cannot be empty")
return summary, body
def _existing_sequences(data_dir: Path, date_compact: str) -> list[int]:
directory = comments_dir(data_dir)
if not directory.is_dir():
return []
found: list[int] = []
for path in directory.glob(f"release-{date_compact}-*.json"):
match = _SLUG_RE.match(path.stem)
if match:
found.append(int(match.group(3)))
return found
def next_sequence(data_dir: Path, when: datetime) -> int:
"""1-based sequence for this date. Two releases on one day are never ambiguous."""
return max(_existing_sequences(data_dir, when.strftime("%Y%m%d")), default=0) + 1
def build_slug(when: datetime, sequence: int) -> str:
return f"release-{when.strftime('%Y%m%d')}-{when.strftime('%H%M%S')}-{sequence:02d}"
def build_designator(when: datetime, sequence: int) -> str:
"""``2026-10-03 #2 14:32:05`` - readable, and quotable when going back to something."""
return f"{when.strftime('%Y-%m-%d')} #{sequence} {when.strftime('%H:%M:%S')}"
def create_comment(
data_dir: Path,
text: str,
author: str = "operator",
now: datetime | None = None,
status: str = STATUS_DRAFT,
facts: dict[str, Any] | None = None,
) -> ReleaseComment:
"""Author a new comment. Refuses an empty note."""
summary, body = parse_comment(text)
when = now or datetime.now(UTC)
sequence = next_sequence(data_dir, when)
comment = ReleaseComment(
slug=build_slug(when, sequence),
designator=build_designator(when, sequence),
date=when.strftime("%Y-%m-%d"),
time=when.strftime("%H:%M:%S"),
sequence=sequence,
summary=summary,
body=body,
author=author,
authored_at=when.isoformat(timespec="seconds"),
status=status,
approved_at=when.isoformat(timespec="seconds") if status == STATUS_APPROVED else None,
facts=facts or {},
)
path = comment_path(data_dir, comment.slug)
if path.exists():
# Same second, same sequence. Never silently overwrite someone's note.
raise CommentError(
f"{comment.slug} already exists. Wait a second or edit the existing comment."
)
_write_json(path, comment.as_dict())
return comment
def load_comment(data_dir: Path, slug: str) -> ReleaseComment | None:
path = comment_path(data_dir, slug)
if not path.exists():
return None
try:
return ReleaseComment.from_dict(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, KeyError):
return None
def list_comments(data_dir: Path) -> list[dict[str, Any]]:
"""Every comment, newest first. Read-only."""
directory = comments_dir(data_dir)
if not directory.is_dir():
return []
out: list[dict[str, Any]] = []
for path in sorted(directory.glob("release-*.json"), reverse=True):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
out.append(
{
"slug": data.get("slug"),
"designator": data.get("designator"),
"date": data.get("date"),
"sequence": data.get("sequence"),
"author": data.get("author"),
"status": data.get("status"),
"summary": data.get("summary"),
}
)
return out
def update_comment(
data_dir: Path,
slug: str,
text: str | None = None,
status: str | None = None,
now: datetime | None = None,
) -> ReleaseComment:
"""Edit a draft, or approve it.
An **approved** comment is frozen. Changing an approved note is not editing - it is a
different claim about a different state, so it needs a new comment and a new
publication. This is the same rule the publication itself follows, deliberately.
"""
comment = load_comment(data_dir, slug)
if comment is None:
raise CommentError(f"no such release comment: {slug}")
when = now or datetime.now(UTC)
if status == STATUS_APPROVED:
if text is not None:
summary, body = parse_comment(text)
if (summary, body) != (comment.summary, comment.body):
raise CommentError(
"cannot change the text of a comment while approving it. Edit the "
"draft first, then approve."
)
if comment.status != STATUS_APPROVED:
comment.status = STATUS_APPROVED
comment.approved_at = when.isoformat(timespec="seconds")
elif text is not None:
if comment.status == STATUS_APPROVED:
raise CommentError(
f"{comment.slug} is approved and frozen. Write a new comment: a changed "
"note is a new release, not an edit of this one."
)
comment.summary, comment.body = parse_comment(text)
_write_json(comment_path(data_dir, slug), comment.as_dict())
return comment
def require_approved(data_dir: Path, slug: str) -> ReleaseComment:
"""The publication gate. Raises if the comment is missing or still a draft."""
comment = load_comment(data_dir, slug)
if comment is None:
raise CommentError(
f"no release comment {slug}. A release needs an approved comment: author one "
"with --comment, then --approve-comment."
)
if comment.status != STATUS_APPROVED:
raise CommentError(
f"release comment {comment.designator} is a DRAFT. Read it, change it if it "
"is wrong, then --approve-comment."
)
return comment
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(tmp, path)
def draft_comment_text(record: dict[str, Any], author: str) -> str:
"""Compose a factual release comment from what was actually measured.
This is the Deal Hunter case: the comment is derived from the real build rather than
written from memory, so it says what shipped, under which profile, with which gates,
and which approvals were granted implicitly by the act of releasing.
"""
profile = record.get("delivery_profile", {}) or {}
approvals = record.get("approvals", {}) or {}
chapters = record.get("chapters", []) or []
deltas = [c for c in chapters if c.get("warnings")]
summary = (
f"{record.get('work')} edition {record.get('edition_number')}: "
f"{len(chapters)} chapters pinned"
+ (f", {len(deltas)} with post-approval delivery deltas" if deltas else "")
)
loud_ok = sum(1 for c in chapters if (c.get("mp3") or {}).get("loudness_ok"))
peak_ok = sum(1 for c in chapters if (c.get("mp3") or {}).get("peak_ok"))
lines = [
summary,
"",
f"package {(record.get('package') or {}).get('object_key')} "
f"({(record.get('package') or {}).get('bytes')} bytes, built "
f"{(record.get('package') or {}).get('built_at')})",
f"delivery {profile.get('sample_rate')} Hz / {profile.get('channels')} ch"
+ (f" / {profile.get('bit_rate_kbps')} kbps" if profile.get("bit_rate_kbps") else "")
+ f", +{profile.get('lead_silence_s')}s lead and +{profile.get('trail_silence_s')}s "
f"trail silence, {profile.get('target_rms_db')} dB RMS target",
f"voices {approvals.get('voices_explicitly_approved', 0)} explicitly approved; "
f"{approvals.get('voices_implicitly_approved', 0)} approved implicitly by this "
f"release (they speak in it); "
f"{approvals.get('voices_in_profile_without_lines', 0)} in the profile are not "
"covered because they do not speak",
f"gates loudness_ok on {loud_ok}/{len(chapters)}, "
f"peak_ok on {peak_ok}/{len(chapters)}",
]
if deltas:
lines += [
"",
f"NOTE: {len(deltas)} chapters shipped longer or resampled than the reviewed "
"chapter wav. The release pins the DELIVERED bytes; the per-chapter review "
"covered the pre-delivery artifact. See delivery_profile.",
]
return "\n".join(lines) + f"\n\nAuthored by {author} from the measured build."