Files
UmbraTrans/docs/data-model-alignment-v1.md
umbra audio 0c35eb2d03 Allow the address the service binds to as a Host value
The deployment listens on 192.168.123.14 and TrustedHostMiddleware rejected that same
address, so every request by IP returned a bare 400 Invalid host header - including
/api/health, which is indistinguishable from the service being down. It has cost this
project twice.

Found when the first pairing attempt from 192.168.123.238 came back 400 with no body
and no logged detail. The pairing code was fine and went unspent; the request never
reached the route.

Both umbratrans.bdmurphy.me and 192.168.123.14 are now allowed. The 0600 on the env
file is unchanged, and the correction is recorded in the alignment doc's transport
note so the audio node is not sent looking for a service outage.
2026-10-03 07:51:14 -07:00

53 KiB
Raw Blame History

Data model alignment — UmbraTrans ↔ UmbraAudio

Status: proposal from the controller (oldbox, 192.168.123.14). Not ratified. Date: 2026-10-02 Read first: your manifest.md §10, §13, §14.2, §15. This document does not repeat your inventory; it says what the controller side must hold for your data to land in it without being re-derived.

0.1 Decisions recorded (2026-10-02)

# decision effect
D1 The controller is authoritative. It must be, for the system to function. It does not specify the algorithm; each node decides how, and declares what it can do. DECISION-1 answered. Unblocks the read-only mirror. Costs a test suite migration, acknowledged.
D2 Compute and capability design is deferred to its own phase. Not a blocker for this one. Until it exists, work routes to a node by name, not by capability. See §2.3.
D3 A character may belong to several series, not exactly one. Many-to-many. Corrects an assumption this document previously made.
D4 A character has a personality, captured by LLM analysis — not merely a name. Used to check other books in development, and to carry the personality through translation. New. §1.3.
D5 A character's voice is consistent across every utterance and every book. Hard invariant. §1.4.
D6 A character may have more than one voice, each version designed, captured and stored, each with a reason. New, and more specific than this document asked. §1.4.
D7 Personality is one thing per character: global across books and language-neutral. "No matter the language or situation the personality should be the same for readers." Design intent, stated as such. Scope is the character alone. §1.3
D8 A voice is a matrix of parameter values. A variant is a small, documented change to that matrix — not a separate named voice. The mechanism behind a variant. §1.4
D9 A work is mutable until publication and frozen after it — "like software prior to deployment". After release, a set of items must not change, for backward compatibility. A release boundary. projects.status = 'released' already exists and is that boundary. §1.5
D10 Character attributes are defined in the first work, and change only explicitly. A later work may force a change: John goes through trauma in book one; in book two the situation costs him a leg. Attributes are time-scoped, and changes are events, never silent edits. §1.5
D11 Not every character persists across books. A subset recurs; the rest are local to one book. Persistence is identified, not inferred. Declared, not derived. §1.6
D12 A character's attributes freeze at the release of the first book in which they appear — not at each book's release. The freeze is anchored per character, once. I-G. §1.6
D13 Not every character needs every aspect captured. Absence is legitimate, so absence must be typed rather than null. §1.6
D14 Published editions change. Corrections, a new cover, a new foreword — that is a new release, a new edition. Editions are a first-class release unit. §1.7
D15 The freeze unit is a single work, not a series. Book 1 may be released while book 2 is still being written. Independent release. Already implied by projects.status.
D16 A revealed-recurring character is linked forward, not merged. A book_local character revealed to recur keeps its released entity and gains a recurs_as edge. Protects the freeze absolutely (I-G). A retelling from the earlier book's viewpoint treats them as two people. §1.6.4

Your next steps, in order:

  1. Read §1. Two things matter more than the rest: a character who is an author inside the fiction is not a byline (§1.2), and disposition is not circumstance (§1.5.1).
  2. Do the seven changes in §5. All additive, none needs the controller.
  3. While you do them, start Stage 0 and Stage 1 of §10. Both are read-only, neither needs anything from §4, and there is no reason to wait for the datastore work.
  4. Run the §6 validation protocol. The per-phase evidence shapes are tabulated in §6.6 — one row per phase — so the two sides are mechanically comparable rather than approximately so.
  5. Contact the controller with the reports. Do not begin data migration yet: §7 says why, and the order is not negotiable.

Readiness. Complete enough to act on. Four gaps were found by reading it against the question you just asked, and closed: the per-phase report fields, the selection rule for the identity probe, the transport details, and the connection sequence itself (§10). If you find a fifth, say so rather than working around it.


1. Vocabulary — read this before anything else

term means has identity here login?
organization owner of the work; the tenant boundary yes —
user human worker who performs work. Owns nothing yes yes
publisher party a work is published under yes no — never
author the writer of the story. Carries the byline yes no
character a being that appears in a story. May appear in several series (D3) not yet no
speaker a character that has lines in a given work. An observation, not a kind not yet no
voice what performs a character, per language. Primary plus reasoned variants not yet no
personality an LLM analysis of a character's disposition. Derived, versioned, never edited in place not yet no

1.1 The chain

author ──writes──▶ series ──┬──▶ work ──▶ edition* ──▶ released artifacts
                          │            │                  (immutable, content-addressed)
                          │            │
                          │            └──▶ character ──┬──▶ primary voice   (per language)
                          │                            ├──▶ voice variants  (each a matrix delta, with a reason)
                          │                            ├──▶ personality     (one per character, language-neutral)
                          │                            └──▶ attributes      (time-scoped, 3 classes, some absent)
                          │                                     └── appearance in a work (has_lines → "speaker")
                          │
                          └── character appears in MANY series; series contains MANY works
                                    │
                    * editions: a correction, a new cover or foreword is a NEW EDITION,
                      a new release — never an edit to the one that shipped

A character is persistent or local (D11). book_local characters are the majority and are subject to the same rules; only series_persistent characters carry cross-book obligations. This is the one place where your own data quality finding and the model meet — see §1.6.

1.2 Three things that are easy to get wrong

A speaker is not a voice. Your utterances.speaker_name names a character. The voice is what performs it, and the same character is performed by a different voice in each language. If "speaker" is modelled as "voice", a translated audiobook cannot have the same character with a different voice, and every language needs its own cast.

Not every character is a speaker. A character with no lines is still a character. "Speaker" is a derived fact — this character has utterances in this work — and should be a query or an appearance flag, never a classification in its own right. Your speaker counts (93/68/71 for Last Dwarf) therefore reconcile against a character count, and today they do not (§15.2). That gap is yours and already known; you are not being asked to solve it before alignment.

A character can be an author inside the fiction — and that is the trap. A character may write a book within the story. That is the character's occupation, a fact about them. It does not make them an author and does not move them out of the character hierarchy: the book author can make a character an author, and the character remains a character. So the word appears in two unrelated places and they must not share a table:

  • authors — the byline on a work, a real-world party, reusable across works
  • characters.occupation — a value like author, scribe, innkeeper, inside the fiction

Merge these and every in-story author becomes a byline, and the credit pages of every book are wrong. It is the most expensive mistake available in this alignment.

1.3 Personality (D4, D7) — one per character, and it needs lineage

A character is not a name; it has a disposition, and capturing it is LLM work. Scope is now settled and deliberately narrow: one personality per character, holding across every book it appears in and every language it is published in. Your framing is the reason it is worth being strict about: no matter the language or the situation, the personality is the same for the reader. The variation belongs to the performance, not to the disposition.

Three consequences:

  1. Personality is derived, so it is an artifact, not a field. artifacts with kind = 'character_profile' and derivation carrying model, prompt version and inputs. "Why does this character read as sarcastic in the German edition" then has an answer you can point at.
  2. Re-analysis creates a version; the accepted one is a pointer. Never edited in place (I-D). The previous version stays reproducible.
  3. Language-neutral, so lang is not part of its identity. Fidelity across a translation is measured against this one artifact. If a translation genuinely cannot carry the disposition, that is a finding about the performance, not a second personality — and treating it as a second personality would make the inconsistency undetectable.

1.4 Voice: a matrix, and variants as deltas (D5, D6, D8)

A voice is not a reference to a model. It is a matrix of parameter values defining multiple aspects of the output, and a variant is a small, documented change to that matrix — which is a much better answer to "what is a variant's scope" than anything this document proposed. It means the mechanism is the same in every case: same identity, different values, and the difference is data.

D5 — the invariant. A character's voice stays the same across every utterance and every book. That makes the character→voice binding the unit of consistency, not the utterance and not the work. One primary voice per (character, language), and it does not change because the character appears in a new book.

D6 — variants are reasoned departures. All three of your reasons are distinct kinds, so a closed vocabulary rather than free text — a variant with no reason is indistinguishable from a bug:

reason what it is
sarcasm_mimic deliberately altered delivery, imitating another
disguise_roleplay the character is acting, masked, or playing another part
technical_degradation distorted by a device, injury, or masking in the fiction

Invariants for the validator to check:

  • I-A exactly one primary voice per (character, lang)
  • I-B that primary voice is identical across every book the character appears in
  • I-C no voice variant without a reason
  • I-D personality is never edited in place; re-analysis creates a new version
  • I-E a voice variant records the matrix values it changes, not just a name

What you told us about how this is achieved. Keeping a voice consistent required modifying the TTS system itself, and the audio system now has a library capability to capture a character voice. So voice capture is node work with a real cost — a capability in its own right (voice.capture_character), not a step inside rendering. And TTS is under active change, which is the argument for versioning definitions as data rather than code.


1.5 Character attributes change over the story (D9, D10) — the largest new thing

"Books in a series are variable until publication, like software prior to deployment. Versions can be changed, but after production release a set of items must remain the same for backward compatibility. Character attributes are defined in the first book and must change explicitly due to issues in another book. John goes through trauma in book one. In book two the situation causes a leg to be amortated, changing the character."

This is a temporal model, and it is not optional — without it, "John lost a leg" is an attribute with no position in the story and nothing can be reproduced.

1.5.1 Three classes of attribute, and they behave differently

The tempting mistake is one characters.attributes column. It cannot work, because the three kinds answer to different rules, and your own answers already separate them:

class examples scope mutability
identity name, aliases the character rare; changes recorded
disposition personality (D7) the character alone versioned, never in place (I-D); language-neutral
circumstance physical state, situation, possessions the timeline time-scoped; changes explicitly (D10)

Your amputation example is the third class. It does not contradict D7. Losing a leg is a circumstance; whether it alters disposition is an authorial judgement recorded as an explicit change, never an automatic consequence. If disposition and circumstance shared a table, those two rules would collide in the schema and someone would eventually resolve the collision the wrong way — by quietly re-deriving personality from circumstance, which would destroy exactly the consistency you designed for.

1.5.2 The timeline, and where publication sits

A series has an ordered set of works. Attributes have an effective position on that timeline and an origin:

  • D10 — "defined in the first book". An attribute has an introduction point. It applies from there until changed.
  • D10 — "must change explicitly". A change is a new row with a cause, never an update. So the history of a character is queryable: why does John have no left leg?
  • D9 — mutable until publication, frozen after. The vocabulary already exists: active/on_hold are pre-release and mutable; released is the freeze; archived follows. D14 then put released on the edition rather than the bare work (§1.7), and D15 fixed the unit at a single work.

I-F a released work's attributes are not altered in place; a later change is effective from a later work. Backward compatibility means book 1 still renders the way it shipped, which is a property of the immutable artifacts already in artifacts, plus this rule on how attribute states are written.

Refined by D12: the freeze is anchored per character, at the release of the first work they appear in — not per work, and not at every release. A character introduced in book one is frozen when book one is released; book two's changes are new states effective from book two. That is I-G, and it is stricter than I-F for a recurring character and identical for a book-local one.

1.5.3 The one thing your answer leaves undefined

D9 says "a set of items must remain the same" without naming the set. We have assumed the minimum that makes the rest work:

frozen — the released work's text; its rendered artifacts (already immutable and content-addressed); the attribute states effective at that point in the timeline; its bylines and publisher, which are contractual.

still mutable — attributes for works after it; anything about an unreleased work.

Answered, 2026-10-02: the freeze unit is a single work (D15), editions are real and a change to a published work is a new edition (D14), and a character's attributes freeze at the first book's release (D12). D13 added that capture is selective, so absence is typed. D11–D13 and §1.7 supersede the assumptions made above; the three-class split and I-F stand.

1.6 Character persistence, and capture that is deliberately partial (D11, D12, D13)

Three separate things, and the middle one is more than bookkeeping.

1.6.1 Persistence is declared (D11)

Only a subset of characters recur across books; the rest are local to one. And the character must be identified as persistent — so this is a declared attribute, not something inferred from name overlap. That matters more than it sounds:

Your §15.2 records three-way speaker-count disagreements — Last Dwarf 93/68/71, DealHunter 61/64/97. A plausible share of that is different characters who share a name, book to book: "The Big One" in book one is not necessarily "The Big One" in book two. Declaring persistence gives you a way to tell those two cases apart instead of reconciling them into one row.

So: characters.persistence ∈ {book_local, series_persistent}, declared, and reviewable. Cross-book obligations attach only to series_persistent.

  • I-A (one primary voice per (character, lang)) holds for every character.
  • I-B (that voice identical across every book) is only meaningful for persistent characters; for a book-local character it is vacuously true, and that is correct rather than unmodelled.

1.6.2 The freeze is anchored once, per character (D12)

A character's attributes freeze at the release of the first book in which they appear — not at each book's release, and not at the series. So a character introduced in book 1 and released with book 1 has their book-1 state frozen then; book 2's changes are new states effective from book 2, and book 1's rendering is untouched.

I-G — a character's attribute states at or before their first released appearance are never altered in place. Later change is a new row with a cause (D10).

1.6.3 Capture is selective, so absence must be typed (D13)

Not all characters need all the aspects captured. That is a design decision and it is sound — analysing the personality of a walk-on character is waste. But it has one sharp edge:

NULL is ambiguous. A missing personality artifact means one of three quite different things: not attempted, attempted and judged not applicable, or attempted and it failed. Only the third is a defect, and a model that collapses all three cannot tell you which happened — it will report a healthy system as incomplete, or an incomplete one as healthy.

So character_attribute_states carries an absence reason rather than being nullable: not_attempted · not_applicable · in_progress · superseded. The validator then requires that an attribute present for one character and absent for another has a reason, and that in_progress is never treated as satisfied.


1.6.4 The merge question this creates

Persistence is declared, and the two kinds are different entities. So: a character who is book_local in book one and turns out to be recurring in book two — the reveal that a minor figure is the antagonist you have been meeting. You now hold two entities for one person, and merging them would alter attributes that I-G has frozen.

This needs a decision and we are not making it for you:

  • Forward-only link — CHOSEN (D16). The book-one entity stays exactly as released, and a recurs_as edge points forward. Book one does not change, ever.
  • Merge with a snapshot. One character, and book one's attributes are copied forward as a frozen historical state.

Chosen because the freeze is what protects reproducibility, and a merge puts it at risk for a gain that is real but retrospective. The cost is deliberate and should be written down: a retelling from the earlier book's point of view will treat the two as different people. That is a consequence of the choice, not an oversight.


1.7 Editions (D14, D15) — a release, not an edit

Corrections, a cover change, a new foreword — that is a new release, a new edition.

So editions sit between the work and the artifacts:

work ──▶ edition 1 (first publication, frozen) ──▶ the artifacts that shipped
    └──▶ edition 2 (corrected reissue)        ──▶ some artifacts unchanged, some new

Three consequences, one of them very good:

  1. projects.status = 'released' moves onto the edition, not the bare work. The work itself is not "released" — it has releases. This is the first thing D14 changes about the existing schema.
  2. Content-addressing makes unchanged content free. An edition that corrects one paragraph and changes the cover is mostly the same artifacts: the unchanged chapters have identical content_hash and are shared with edition 1. Only genuinely changed content becomes a new artifact. This is the payoff for having made identity the bytes rather than the filename.
  3. The freeze is per edition, and that resolves the obvious tension. If a correction in edition 2 contradicts something frozen in edition 1, the answer is not "break the freeze": edition 2 references a new attribute state, and edition 1 continues to reference the one it shipped with. Backward compatibility holds because nothing is rewritten — each edition points at its own set. I-H — an edition's artifact set is fixed at its release and never gains or loses a member.

I-H — an edition's artifact set is immutable once released.

2. What exists on this side, and what does not

Applied to the scratch database; not yet applied to the deployment.

2.1 Exists

table holds
authors the byline. name, display_name, optional user_id, optional owner_organization_id
project_authors who is credited on which project, in which capacity, in which language. credit_role ∈ {author, translator, editor, narrator, cover_artist, illustrator}
publishers, project_publishers the publication boundary. Optional per-language, so co-editions work
artifacts identity is the bytes: content_hash (sha256), storage_key, byte_size, kind, lang, status ∈ {intermediate, final, superseded}, parent_artifact_id, derivation jsonb
job_artifacts what a job consumed and produced. Makes "step 1 on node 1, then step 2 on node 3" legible after the fact
projects the work, owned by exactly one organization. All projects in an organization are visible to all members of it. status ∈ {active, on_hold, released, archived} — the freeze vocabulary already exists and needs no migration; D14 puts released on the edition, not the bare work

artifacts.kind is deliberately an open string, not a CHECK. What a project produces is open-ended and a constraint now means an ALTER for every pipeline added later.

2.2 Does not exist — and will, before your data lands

table holds why
series parent of a project recurring characters span books
series_characters many-to-many (D3) a character in several series is not two characters
characters being in a story: name(s), occupation separate from identity (§1.2), persistence ∈ {book_local, series_persistent} declared (D11) §1.2, §1.6
character_recurs_as forward-only link when a book-local character is revealed to recur (D11) §1.6.4 — decision needed, §8
character_appearances (project, character, has_lines) "speaker" is this, not a table — a character may appear without speaking
voices what performs a character the artefact, not the binding
character_voices (character, lang, voice, is_primary) — exactly one primary per pair (I-A) D5: consistency across utterances and books
character_voice_variants (character_voice, reason ∈ {sarcasm_mimic, disguise_roleplay, technical_degradation}) + deltas D6, D8; I-C, I-E
series_works ordered membership of a series: (series, work, sequence_number) the timeline attributes are effective on (D10)
character_attribute_defs the vocabulary of attribute keys and their classes (identity / disposition / circumstance) §1.5.1
character_attribute_states (character, attribute, effective_from_work, effective_to_work NULL, value, change_reason, provenance, absence_reason) time-scoped, append-only. A change is a new row (D10, I-F); absence is typed, not null (D13)
editions (work, edition_number, released_at, change_summary, supersedes_edition) D14 — a release unit. projects.status = 'released' becomes an edition state
edition_artifacts the immutable set an edition shipped (I-H) D14
source_revisions immutable revision of a source text a project cannot have one unversioned source; frozen at released
source_units a stable unit of source text, with source_unit_key your blocking item, §5.1
voice_matrix_values the parameter values that make a voice a voice D8. A variant is a delta on these, not a new voice
voice_variant_deltas (character_voice, reason, attribute_key, from_value, to_value) I-E: a variant records what it changed

Personality is not a table here — it is an artifact (§1.3), with the accepted version pointed at from characters.

2.3 Compute and capability — deferred by agreement (D2)

We agree this is the same idea seen from two sides: what must I compute, and what can this hardware do it on. The terms differ — we say VRAM, RAM, CPU, model, accelerator; you say analysis.speaker_attribution — but the question is one question.

The design is not done and this phase is about data. So it is deferred, not pending, and it does not block the work below. Two consequences to be clear about:

  1. Until it exists, work routes to a node by name, not by capability. That is fine for a one-node-per-product world and it stops being fine the moment Mini-EQ and the others join.

  2. Your §4.7 capability list is still worth publishing now, as a declared vocabulary even with no scheduler behind it — because it is the thing the scheduler will later match against, and writing it down now costs nothing:

    audio.parse_manuscript · audio.segment_utterances · analysis.{character_discovery, speaker_attribution, speaker_clarity, name_token_scan} · character.{personality_analysis, consistency_check} (new, D4) · voice.{capture_character, reference_generation, attribute_measurement} · audio.{render_utterances, stitch_chapters, normalize_loudness} · export.{m4b, mp3_package} · review.speaker_QA

    Note character.personality_analysis and voice.capture_character are new since your §4.7 — the first is D4, the second follows from having modified the TTS system.

3. Identity rules you must be able to satisfy

These are the rules the data model depends on. Four of them are your §8.3 blockers and we agree on all four.

  1. A source unit has an identity that is not its index. utt_{index:04d}.wav is not durable: re-segmentation and utterance deletion renumber it. We want source_unit_key, content-addressed at generation time, with filenames unchanged. Adding it is additive and changes no existing filename.
  2. An artifact's identity is its bytes. sha256. Same bytes in two projects are the same artifact. That is what makes caching, deduplication and "has this already been done" one question instead of three.
  3. A source revision is immutable. Artifacts name the revision they came from.
  4. Every blob gets its hash at generation, not at export. If the hash is computed during migration it is a hash of a moving target.

4. What the controller side owes you before your data lands

Stated so you know the sequence and are not blocked on us.

# item note
1 Apply migration 0003 compute + datastore. Currently unapplied to the deployment
2 series, series_characters, characters, character_appearances, voices, character_voices, character_voice_variants, source_revisions, source_units §2.2
3 Capability requirements §2.3
4 Ingest API with bulk there is none today. One artifact per request will not carry four projects
5 Revoke PUBLIC execute on app functions; set default privileges your B4. Before any further migration
6 Move the application off the table-owner role your B1 / our G2. The roles exist (umbracontrol_worker, umbracontrol_api) and have grants; nothing uses them
7 Object storage + signed URLs your DECISION-6. Audio bytes never go through the API — the API carries metadata and lineage, storage carries bytes
8 Artifact-write scope on a node credential node scopes today are compute:read/claim/complete/heartbeat — deliberately no data access. Changing that is a decision, taken deliberately

Item 8 is the one that matters for you: as things stand, a node cannot write to the datastore at all. That was on purpose, and it becomes wrong the moment you are the writer.


5. Seven changes to make on your side

All additive. None requires the controller. Do these before validation, because two of them change what your data means and doing them later means a second migration.

5.1 source_unit_key — highest leverage item you have

Your M4-04. Content-addressed, recorded at generation, filenames unchanged, is_current unaffected. This is the one identifier that cannot be retrofitted without re-deriving it from the audio. Everything else in this document depends on it.

5.2 SHA-256 per blob, at generation

Not at export. Every rendered WAV and every voice asset.

5.3 Map your speaker model onto the chain

For each project, produce a mapping: free-text speaker_name → which character it names, and mark which have utterances (and are therefore speakers) and which do not. You do not have to resolve the disagreements in manifest.md §15.2 to do this — you have to show them. Unresolved names are recorded with a reason, never discarded.

5.4 Publish your vocabularies as JSON

From manifest.md §14.2: utterance kinds, speaker kinds, character kinds, capture modes, phonetic provenance. Plus your artifact kinds and statuses. The controller publishes the same shape in §6.1; the diff is mechanical.

5.5 Keep per-project schema fingerprints current

You identified two fingerprints on this machine. They are real drift. Record them at validation time and keep reporting drift rather than normalising silently.

5.6 Give each personality analysis a version and a provenance

New since D4. A personality is LLM output, so it needs the same treatment as any other derived artifact: model, prompt version, inputs, and a version of its own. Re-analysis creates a new version; the old one stays reproducible. If the analysis runs per language, say so, and the lang is part of the artifact.

5.7 Decide and record one thing

Per the project specification: is utterance_index kept as a display ordinal, or retired in favour of source_unit_key? We recommend keep — it is useful for a reviewer and it must not be load-bearing. Record the decision; we will not make it for you.


6. Validation protocol

The purpose of the first contact is to establish that both sides mean the same thing by the same words. Agreement obtained by reading each other's prose is not evidence, and on this project that mistake has been made repeatedly — a check that passed because its mechanism did nothing is worse than one that fails.

So: every phase produces evidence. A phase without evidence is BLOCKED, never PASS.

Phase 1 — Vocabulary diff

Both sides emit the same JSON shape (§6.6) listing their enumerated sets. We compare mechanically. A difference is a finding, not a discussion. Expect differences; the point is that they are found by diffing rather than by argument.

Phase 2 — Cardinality reconciliation

For each of your four projects, report: distinct speaker_name values, distinct characters those names resolve to, utterances, chapters, and the per-store counts you already have. Our side reports what it expects from your manifest.

This phase is expected to show disagreement, because your §15.2 already records three-way count disagreements. That is a known finding to be recorded, not a blocker.

Phase 3 — Identity probe

We send you a fixed set of source units, named by content, never by index. You return what each resolves to: source_unit_key, character, speaker, language.

If any unit cannot be resolved on your side without utterance_index, say so. That is the answer we need.

Phase 4 — Lineage probe

One audio artifact, described by its chain: source revision → source unit → voice → render. Both sides state the chain. If the two descriptions differ in any link, identity is not aligned — regardless of how similar they look.

Phase 5 — Timeline probe (new, from D9/D10)

Pick a character that changes — your John, or any equivalent. Both sides report the character's attributes at each point in the series timeline: introduced where, changed where, and with what cause. Then:

  • Any attribute state with no effective_from_work is a finding.
  • Any change without a recorded cause is a finding (D10: changes are explicit).
  • Any circumstance-class change that alters the accepted personality artifact is a blocker — it is exactly the conflation §1.5.1 forbids.
  • If the character is released in book 1 and book 2 changes them, both sides confirm that book 1's rendering is unaffected. That is what "backward compatibility" has to mean here, and it is the property I-F exists to hold.

Phase 6 — Voice matrix probe (new, from D8)

For one character and one language: the primary voice's full matrix, and one variant's matrix. Check on both sides that the variant differs only in the values it declares, and that every difference has a reason (I-C, I-E). A variant that changes an undeclared value is the failure this phase exists to catch.

Phase 6b — Persistence and partial-capture probe (new, D11/D13)

For each character, both sides report: declared persistence; which attributes exist; and for each absent one, which kind of absent it is (not_attempted, not_applicable, in_progress, superseded).

Checks:

  • A series_persistent character on one side and book_local on the other is a blocker.
  • Any absence with no reason is a finding (D13 — null is ambiguous).
  • in_progress is never accepted as satisfied.
  • We expect to disagree on the counts here, because your §15.2 disagreements may be book-local characters sharing a name. Disagreement is the finding we are looking for.

Phase 6c — Edition probe (new, D14)

One work with more than one published edition, or, failing that, one work declared released. Both sides report the editions and the artifact set each shipped. Check:

  • Unchanged content appears with the same content hash in both editions, not a duplicate — that is the payoff of identity-is-the-bytes, and if it is not true on your side we need to know before the mirror.
  • An edition's set is fixed: no member added or removed after release (I-H).
  • A book-one character whose attributes changed for book two still shows book one referencing the state it shipped with (I-G, I-H together).

Phase 7 — Round trip

One chapter. You export metadata and hashes; we ingest; you re-derive and confirm the hash set is identical. This is your gate 6, and it is the only phase that proves idempotency rather than asserting it.

6.6 Report format

One envelope, and evidence has a different shape per phase. The first version of this section gave a single skeleton for every phase, which is the same as giving none: it does not say what evidence phase 3 consists of, so you would invent one and the two sides would not be comparable — which is the entire purpose of a machine-readable report.

Envelope, every phase:

{
  "report": "alignment",
  "schema_version": 1,
  "phase": 6b,
  "from": "umbraaudio-m4",
  "node_contract_version": 1,
  "generated_at": "2026-10-02T00:00:00Z",
  "verdict": "pass | fail | blocked",
  "evidence": { },
  "findings": [
    { "id": "F-01", "severity": "blocker | major | minor",
      "description": "...", "affects": ["source_unit_key", "I-G"] }
  ]
}

evidence, per phase — this is the part that makes it comparable:

phase evidence contains
1 vocabularies: { "<set_name>": ["value", …] } for every set in §5.4, plus definition_set_version
2 per_project: [{ project, speaker_names, characters, speakers, utterances, chapters, store_counts: {…} }]
3 resolutions: [{ unit_key, resolved: bool, character, speaker, lang, used_index: bool }] — used_index: true is the finding we want
4 chain: [{ step, entity, identifier, resolved: bool }] for one artifact, in order
5 timeline: [{ work, sequence, state: "released", attribute_states: [{ attribute, value, effective_from, change_reason }] }]
6 matrix: { attribute_key: value } for the primary voice, and variant_matrix for one variant, plus declared_deltas: [{ attribute_key, from, to }]
6b characters: [{ key, persistence, attributes_present: […], attributes_absent: [{ attribute, absence_reason }] }]
6c editions: [{ work, edition_number, released_at, change_summary, artifacts: [{ content_hash, role }] }]
7 chapter, hashes_sent, hashes_recomputed, identical: bool, source_tree_hash_before, source_tree_hash_after

Rules. verdict is pass only when the phase's evidence is present and its checks hold. Empty evidence is blocked, never pass. A field you cannot produce is null with a finding — it is not omitted.

6.6.1 How the identity probe set is chosen (phase 3)

The previous version said "we send you a fixed set" without saying how it was fixed, which would have made phase 3 either unreproducible or impossible.

You choose the set, deterministically, and we send you those exact units. The procedure, so that both sides run the same thing:

  1. Pick one project — your choice, named in the report.
  2. Sort all source_unit_key values ascending (byte order, not locale).
  3. Take every 17th, starting at index 0. That is a prime stride, so it samples across the whole range and cannot systematically miss a region.
  4. Cap at 200 units. If the project has fewer, send all of them.
  5. Report the total count and the stride you used, so we can verify we asked for the same set.

We then resolve each on our side and return what it resolves to. If a unit cannot be resolved without utterance_index, that is the answer — say so rather than improvising an identifier.

6.7 What "aligned" means

Alignment requires: every phase pass with evidence present, and zero blocker findings. A report that says pass with an empty evidence object is rejected unconditionally and returned to you.


7. Do not do these yet

do not why
Begin the read-only mirror (your M4-15) B4 is unfixed: app authorization functions are EXECUTE-able by PUBLIC. An ingest path should not exist until that is closed
Write anything to the controller's datastore yet DECISION-1 is ratified (D1): the controller is authoritative. What is not in place is the machinery to be authoritative safely — B4 unfixed, no non-owner role in use, no ingest API. Until those close, you remain authoritative in fact for your own working area, whatever the decision says
Rename or renumber utterances Your own AGENTS.md rule, and ours: identity is content, never position
Treat an in-story author as a byline §1.2. This is the expensive one
Auto-update anything from the controller A node that fetches and executes code is a remote-code-execution channel

8. Questions — 12 answered, 2 remain

8.1 Answered (recorded above, not repeated)

our question your answer
series or single work? several series — D3
one character split into two speakers? not quite — one character, one primary voice, plus reasoned variants (D5, D6)
one speaker, multiple voices in a language? yes, with a reason and a scope — §1.4
utterance_index display-only? §5.7 — still yours to record
personality per character, or per language? per character — language-neutral (D7)
personality global, or per book? global (D7)
what is a voice variant's scope? a delta on the voice matrix, with a reason (D8)
source revision per project or per series? superseded: the freeze is per work (D15) and releases are per edition (D14)
do released character attributes freeze, or only the rendering? attributes freeze, at the first book's release (D12), and an edition's artifact set is fixed at its own release (I-H)
does a published edition ever change? yes — a new edition is a new release (D14)
is the freeze unit a work or the series? a single work (D15)
does every character need every aspect? no (D13) — which is why absence is typed rather than null

8.2 2 questions, and one that appeared with the answers

  1. A book_local character is revealed to recur — merge, or forward-only link? §1.6.4. Merging is truer about the fiction but would alter attributes I-G has frozen; a forward-only link preserves the freeze absolutely and means a retelling from book one's viewpoint treats them as two people. We recommend the forward-only link, because the freeze is what protects reproducibility and a merge puts that at risk for a gain that is real but retrospective. Your call — it is about the fiction, not the schema.

  2. May a correction in a new edition contradict something frozen in the previous one? §1.7 consequence 3 assumes it may, handled by a new attribute state that only later editions reference (I-H). The alternative is that corrections are text-only and may never contradict character state. We have assumed the former because it is the more common case and it degrades safely — but if character state is legally or editorially fixed at first release, say so, because then I-G becomes absolute rather than per-edition.

8.3 Two we are not asking you to answer

  • Does a character need a stable identity across series? You said a character can be in several series, so yes by implication — but we want your source_unit_key design to settle it before we assume.
  • Cross-project artifact identity stays open on our side too. Our unique index is deliberately per project so one tenant cannot learn another's holdings by colliding a hash. If you need cross-project identity for the same bytes, that is a design decision with a tenancy cost, and we will bring you options rather than a recommendation.

9. What we will send you

Before any data moves:

  • the ingest API contract, including batch size and idempotency rules
  • the artifact-write scope added to a node credential, and exactly what it permits
  • the object-storage decision and how a large artifact is fetched — not through the API
  • the ratifications, including DECISION-1 (D1) and the per-project migration states

10. Connecting to the controller

You asked when you can connect, send data, and test. You can start now, and the stage you can reach today carries no project data at all. Each stage proves the one before it, and no stage sends anything the previous stage has not authorised.

10.1 Transport — three things that will bite you

1. The Host header is mandatory. The deployment accepts exactly one host:

ALLOWED_HOSTS=umbratrans.bdmurphy.me

Connecting to 192.168.123.14:8442 without that header returns 400 on every request, including /api/health. Send Host: umbratrans.bdmurphy.me on everything. This is a TrustedHostMiddleware refusal and it looks like the deployment being down.

2. There is no TLS, despite what the unit description says. The service is described as "loopback/LAN, TLS terminated upstream by Caddy", but Caddy is not running and its configuration is empty. So the reachable endpoint today is plain HTTP on a LAN address:

http://192.168.123.14:8442        with  Host: umbratrans.bdmurphy.me

For a read-only node report that is tolerable on a trusted LAN and we would rather you know than assume. It is not tolerable for the ingest path — see Stage 3. Getting Caddy in front of this port is on our list and is a prerequisite for Stage 3, not an optional extra.

3. The node surface is bearer-token only and correctly refuses everything else. POST /api/node/heartbeat returns 401 without a credential. It is CSRF-exempt by design, because a bearer-authenticated agent has no ambient cookie to ride on. Do not send a session cookie from the audio web UI — it will not help and it widens the surface.

10.2 The four stages

stage what crosses the wire what it needs possible today?
0 — reachability nothing. GET /api/health, and one POST /api/node/heartbeat expecting 401 network route to 192.168.123.14 yes
1 — report your existing scripts/umbra_node_report.py --json, as a heartbeat a node credential we issue yes
2 — read mirror controller serves; you pull. You write nothing Stage 1 clean, plus B4/G2 closed and a read scope not yet — ours to build
3 — write back artifacts and lineage out of you ingest API, object storage, TLS, a write scope not yet — ours to build

Stage 0 is a test, not a handshake. A 401 from the node surface is the correct answer and is the evidence that authentication is working. If you get 200 without a token, that is a finding and you should report it as a blocker rather than proceeding.

Stage 1 needs no schema change and no new endpoint. node_heartbeats.raw is an open jsonb, so the node report rides inside the heartbeat that already exists. Your report is already read-only, already secret-free, and already emits per-project schema fingerprints — it was built for exactly this. Send it as the top-level measurements object (§10.6 gives the exact shape, which is flat — there is no raw key and no agent object).

10.3 Why it starts read-only

The compute substrate proved itself the same way: a node is online because it heartbeated, not because it was created (invariant I24). A node that exists in the inventory but has never spoken takes no work. That is deliberate — it is the difference between a configured machine and a working one.

The same discipline applies here, and it is the reason Stage 1 sends a report rather than data. Your node's first traffic proves it is alive, reachable, running the code it claims, and reporting the schema it actually has. A mirror that starts with data cannot distinguish "the node is broken" from "the data is bad", and you already know one of those is likely — your §15.2 counts disagree.

10.4 How to start Stage 0 now

From the audio node, nothing more than this:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -H 'Host: umbratrans.bdmurphy.me' http://192.168.123.14:8442/api/health
# expect 200

curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Host: umbratrans.bdmurphy.me' -H 'Content-Type: application/json' -d '{}' \
  http://192.168.123.14:8442/api/node/heartbeat
# expect 401 - that is the correct answer

Report both codes in your first message. If either differs from what is written above, say so and stop — a 200 on the second, or a 400 on the first, means something on this side is not what this document describes, and everything below it is built on that assumption.

10.5 Stage 1 — issued, and what it is not allowed to do

Issued on 2026-10-02 against the live controller:

node name umbraaudio-m4
node id aedb4131-4729-4cfb-9d74-b0ac6dbb6daf
key prefix v8RWOnkM
scopes compute:read, compute:heartbeat

The key itself is deliberately not in this document — it is in /home/umbra/.config/umbratrans/audio-node.json, mode 0600, on oldbox. Ask for it over whatever channel you already use for credentials.

Four things were checked on the running controller before this was called issued, and all four are the kind that only mean something if they were observed rather than assumed:

check result
POST /api/node/heartbeat with no credential 401
same, with the credential 200, node moves provisioning → online
POST /api/node/jobs/claim with this credential 403 node_scope_required
the job that was there to be claimed still queued, no lease owner

That third and fourth pair are the ones worth reading twice. The claim was refused at the scope check and never touched the job — a refusal that had already half-claimed something would leave the queue believing a node is working when it is not. compute:claim is a separate scope, so the audio node cannot take work until someone deliberately issues a second credential.

10.6 What your heartbeat should carry

Corrected 2026-10-02, after this document was already in your hands. The payload shape originally given here was {"agent": {"version": ...}, "raw": {...}}. That shape is wrong. It returns 200 OK and stores nothing — verified, not assumed: the row came back with agent_version=NULL and raw={}. An earlier version of this section was wrong in exactly the way that is hardest to notice, so read the correction rather than trusting the shape you were first given. If you have already been sending the old shape, stop; nothing you have sent has been recorded.

The request body is flat. There is no agent object and no raw key:

{
  "agent_version": "umbra-audio-0.1.0",
  "active_jobs": 0,
  "resident_models": [],
  "devices": [],
  "measurements": { ...your existing report... }
}

Everything except measurements is optional and defaults to empty. measurements is an open jsonb and is written verbatim into the raw column, so your existing scripts/umbra_node_report.py --json output rides inside it as-is — no migration on this side, no new endpoint, no new schema on yours.

Keys that have a name of their own are lifted out into real columns; everything else stays in raw and is queryable as JSON. Recognised: cpu_busy_pct, ram_free_mb, vram_free_mb, vram_total_mb, disk_free_mb, load_avg_1m.

The working command:

curl -sS -X POST https://umbratrans.bdmurphy.me/api/node/heartbeat \
  -H 'Authorization: Bearer <KEY>' \
  -H 'Content-Type: application/json' \
  -d '{
        "agent_version": "umbra-audio-0.1.0",
        "active_jobs": 0,
        "resident_models": [],
        "measurements": {
          "cpu_busy_pct": 0,
          "ram_free_mb": 0,
          "disk_free_mb": 0,
          "sql_tables": {"books": 4, "tracks": 11, "speakers": 461},
          "speaker_name_agreements": {"last_dwarf": [93, 68, 71], "dealhunter": [61, 64, 97]}
        }
      }'

Arbitrary nested JSON survives intact. The example above stores raw->speaker_name_agreements as {"dealhunter": [61, 64, 97], "last_dwarf": [93, 68, 71]} — checked, not assumed.

Transport note. Use http://192.168.123.14:8442 directly. Both that address and umbratrans.bdmurphy.me are accepted Host values as of 2026-10-03; before then the service bound to 192.168.123.14 while rejecting requests to it, so every request by IP returned 400 Invalid host header — including /api/health, which makes a healthy service look dead. If you see a bare 400 with no JSON body, suspect the Host header before anything else.

10.7 What the response tells you

{"node_id":"...","name":"umbraaudio-m4","status":"online","schedulable":false,
 "status_reason":"online: no devices reported yet","devices":[],"heartbeat_interval_seconds":10}
  • status is provisioning until your first heartbeat, online after. Never set by configuration. A node an administrator has set to draining or retired is not promoted back by heartbeating.
  • schedulable is separate from status and reads false until you report at least one device that is not busy. That is the honest answer: a node with no devices cannot be given work, and it is better that it says so than that it looks available.
  • heartbeat_interval_seconds is 10. Miss that window and the node goes offline.

To become schedulable, report a device. kind must be exactly one of cpu, gpu_discrete, gpu_integrated, npu, tpu — any other value is a 422. throughput_factor is measured by the node rather than declared by a human: a declared factor is a guess that goes stale the moment quantisation or context length changes, and a stale factor sends work to the wrong machine.

"devices": [{"kind":"cpu","label":"M4","ram_total_mb":16384,"ram_free_mb":8192,
             "throughput_factor":1.0,"supports_accelerators":[]}]

Contact: the controller on oldbox, 192.168.123.14:8442. Stage 1 credential is issued — Stage 2 next.