Design-phase contract for moving UmbraAudio + the future UmbraTrans review UI
onto the shared enterprise PostgreSQL database without interrupting audiobook
production. No database contacted, no migration SQL, no production data written.
Verified against the real schema (infra/postgres/001_control_plane.sql, 13
tables, all identity/tenancy/audit) and the full current audio codebase.
Key findings:
- No local accounts exist to migrate; the only identity traces are the literal
string 'user' in two review tables. One canonical enterprise user.
- No durable utterance identity: artifacts are named by mutable
utterance_index (ch{N}/utt_{idx:04d}.wav), which spec 18 forbids as a
durable ID. Proposes a content-addressed source_unit_key recorded at
generation time, leaving on-disk names untouched for continuity.
- BLOCKER: umbracontrol_worker matches zero RLS rows and cannot attribute an
audit event, so worker identities are unrepresentable. Needs
service_identities + job_leases, not a GUC workaround.
- RLS machinery is owned by the postgres superuser because the bootstrap pipes
the schema through psql -U postgres. FORCE ROW LEVEL SECURITY would cause
42P17 infinite recursion; recorded as a load-bearing invariant.
- 10 verified application defects, incl. download traversal via string-prefix
containment and raw upload filenames.
No UmbraAudio behaviour changes. Adapter boundary keeps SQLite+filesystem as
the default repository so cutover is a flag flip and always reversible.
128 KiB
Spec 20 — Umbra Enterprise Database Integration: Continuity, Access, and Schema Contract
- Status: DISCOVERY AND DESIGN ONLY. No production migration SQL. No connection to the production database. No DDL/DML executed.
- Date: 2026-09-25
- Scope: Align UmbraAudio (the English audiobook engine) and the future UmbraTrans translation/review UI onto one shared enterprise PostgreSQL database without interrupting audiobook production.
- Authoritative inputs read:
infra/postgres/001_control_plane.sql(584 lines, verified byte-identical to/Volumes/Dock/worstauthor/oldbox/postgres/001_control_plane.sql),infra/bootstrap_umbracontrol_database.sh,specs/18-UmbraTrans.md,specs/19-enterprise-control-plane.md, and the full current UmbraAudio source tree. - No secrets, credentials, connection strings, tokens, or key material appear in this document.
A. Executive summary
A.1 What was actually verified
The shared control-plane schema at version 001_control_plane exists as a file and has a
bootstrap script. It contains 13 tables, all tenancy/identity/audit:
schema_migrations, organizations, users, projects, roles, permissions,
role_permissions, user_roles, invitations, sessions, mfa_methods,
bootstrap_tokens, audit_events.
Verified absent (I am not claiming these exist): there is no source book, edition,
work, locale, analysis, speaker, voice, license, translation, audio run, artifact, job, lease,
or idempotency table. There is no project_members table (membership is encoded in
user_roles). There is no password column on users. There is no service-identity
table. Every ebook, voice, translation and audiobook domain concept must be created.
A.2 The three findings that decide the plan
-
There is no local account system to migrate. The prompt asks me to "map any existing local accounts to canonical enterprise user IDs." Verified: the audio system has zero authentication, zero users, zero sessions, and no user model anywhere. The only identity traces are the literal string
'user'invoice_review_history.reviewed_byandexception_reviews.resolved_by. There is exactly one human operator. Mapping therefore means: create one canonical enterprise user, and attribute those legacy'user'strings to that user id during backfill. No per-project users may be invented. -
The audio system has no durable utterance identity, and it names its artifacts by a mutable index. On-disk audio is
chapter_audio/ch{N}/utt_{utterance_index:04d}.wav(generation/chapter_builder.py:512).utterance_indexis renumbered by re-segmentation and byDELETE /api/utterance/{id}, which reindexes the whole chapter and renames files on disk (web/routes.py:3405-3524).specs/18-UmbraTrans.md§3.3 explicitly forbids mutable indices as durable cross-language IDs. The enterprise model must introduce a content-addressed unit key and record it at generation time, while leaving the on-disk filename unchanged so that continuity and the existing staleness markers keep working. -
The
umbracontrol_workerrole cannot read any project and cannot attribute an audit event. RLS is enabled onprojectsand every policy requiresapp.current_user_id(). The worker sets no user GUC, so it matches zero rows.audit_events_insertrequiresactor_user_id = app.current_user_id(), which isNULLfor the worker, so worker actions are anonymous. This is a BLOCKER (see §M). Spec 19 §5.5 anticipated it ("Worker service accounts receive only the permissions required for their queue and artifact scope") but001_control_planenever implemented a mechanism.
A.3 Direction change that needs ratification
specs/19-enterprise-control-plane.md §2.3 and §16 M4 specify that the control plane
imports a read-only snapshot of UmbraAudio and that the English path keeps running in
UmbraAudio unchanged. This document reverses that: the enterprise database becomes
authoritative for project state, source analysis, speakers, voices, translation references,
audio runs, artifacts, lineage and audit, and UmbraAudio reads and writes it as a client.
See §M DECISION-1. Spec 19 §1's narrower prohibition — never put control-plane tables inside
the local per-project SQLite database — remains in force and is honoured.
A.4 Production continuity verdict
Audiobook production can and must continue throughout. The design keeps the existing filesystem + SQLite engine running untouched and introduces a repository/adapter boundary so that "where state lives" becomes a configuration decision rather than a code rewrite. The enterprise database is authoritative for identity, tenancy, metadata and release; the audio worker holds a bounded signed lease and can keep rendering audio if the enterprise database is briefly unreachable, which is the only way to satisfy "production remains available" and "the enterprise database is authoritative" simultaneously.
B. Audio-production continuity assessment
B.1 How an ebook enters the system today
POST /api/projects(web/routes.py:533) orPOST /api/upload-manuscript(web/routes.py:620) receives a.docx/.epub/.txt/.md/.pdfupload.permanent_path = permanent_dir / filename(routes.py:647) — the raw multipart filename is used, unsanitised. See §J.6.project_bootstrap.create_project_from_manuscript_async→load_config(name)→config.py:165ensure_dirs()creates five directories as a side effect of resolving the project.detect_format+parse_manuscriptpopulatechapters(source_textholds the full copyrighted text) andutterances.UPDATE project SET source_path(routes.py:574,665).- With
mode=new, the previous run is copied toarchives/<timestamp>/and thenshutil.rmtreeis applied tovoices/,voice_tests/,chapter_audio/,analysis/(routes.py:712-742). This is the most destructive operation in the system.
B.2 How analysis creates speakers and voices
- Discovery (pass 1-3, LLM via LM Studio,
analysis/character_discovery.py) writesdiscovery/discovery_pass{1,1b,2,3}.json, ending withspeakers.json. - Profiling writes
voice_profiles.json(voices/voice_profile_manager.py:753). - Casting is a fifth parallel store:
voice_assignments.json({character_name: "en-US-JennyNeural"},scripts/assign_voices.py:161) plus the global~/.umbra/voice_registry.db(voice_registry,registry_aliases) andlibrary_speakers(library.py:21). - Voice reference WAVs are written to
.umbra/voices/{slug}_ref.wav(voices/voice_designer.py:248), with~/.umbra/library/voices/as a global set,voice_embeddings/{slug}.npy, and server-sidevoice_prompts/{slug}.pt. - Attribution (
analysis/llm_attributor.py) writesutterances.speaker_name— a free-text denormalised label, not a foreign key tocharacters.
Verified consequence: one speaker exists in five stores under four different keys
(characters.id integer, speakers.json.primary_name, voice_profiles.json name key,
voice_registry.character_name, library_speakers.primary_name). This is the single largest
identity defect in the current system and is the reason §E proposes a canonical speakers
table with speaker_aliases.
B.3 How audiobook jobs are queued, retried and completed
There is no queue and no job table. Verified across umbraaudio/ and scripts/: zero
occurrences of lease, heartbeat, backoff, or exponential.
- Orchestration primitive:
threading.Thread(target=..., daemon=True), at 15 call sites (routes.py:827, 1925, 3139, 3372, 3474, 3657, 3893, 3950, 4039, 4179, 4253, 4565, 5019, 5109, 5380), plusPipelineManager.start()as an asyncio task (pipeline_manager.py:121). - Work is a
forloop overSELECT ... FROM utterances WHERE chapter_id = ? ORDER BY utterance_index. - The only mutual exclusion is
GenerationManager._lock(voices/generation_manager.py:25), which is re-instantiated per HTTP request (routes.py:475-479), so the real guard is the on-diskactiveboolean ingeneration_status.json— a TOCTOU check, not a mutex.ChapterBuilderhas no lock at all (routes.py:5097-5110). - Retry: linear
time.sleep(5 * (attempt + 1))onQwenTTSUnavailableError(chapter_builder.py:615); the batch driverscripts/generate_all_audio.pyhas no retry loop at all (:202-241). umbraaudio/generation/retry.pydefines aRetryHandlerthat nothing imports — dead code.- Job identity is the output filename, derived from
(chapter_id, utterance_index).
B.4 How artifacts are named, stored and retrieved
| Artifact | Path pattern | Versioned? | Checksum? |
|---|---|---|---|
| Per-utterance WAV | .umbra/chapter_audio/ch{N}/utt_{idx:04d}.wav |
No — overwritten in place | No |
| Staleness marker | .../utt_{idx:04d}.wav.txt |
n/a | 16-hex truncated SHA-256 of the voice ref only |
| Chapter master | .umbra/chapter_audio/ch{N}_full.wav |
No | No |
| Chapter MP3 | .umbra/chapter_audio/ch{N}_full.mp3 |
No | No |
| Full book | .umbra/export/{m4b_meta.output_name} e.g. Deal_Hunter.m4b |
No | No |
| AR package | .umbra/export/ar_package/{Base}_{NN}_{Title}.mp3 |
No | No (only size_bytes) |
| AR manifest | .umbra/export/ar_package.json |
Overwritten wholesale | No |
Verified: no SHA-256 or MD5 of any produced audio file is computed or stored anywhere. Every
hash in the repository is a cache key, a voice fingerprint, a watermark bit-vector seed, or a
deterministic TTS parameter seed. Retrieval is via a StaticFiles mount plus 12
FileResponse endpoints, of which zero perform a user authorization check.
B.5 How the API and UI identify a project
A single browser cookie, active_project, set at routes.py:529 and routes.py:616 with
only max_age=86400*365 — no httponly, no samesite, no secure. The complete gate for
all 129 project-scoped routes is:
# web/routes.py:482-491
def _get_active_config(request: Request):
active_slug = request.cookies.get("active_project")
if not active_slug:
raise NoActiveProjectError("No active project selected.")
config = load_config(active_slug)
if not (config.data_dir / "project.db").exists():
raise NoActiveProjectError(f"Project '{active_slug}' no longer exists.")
return config
The cookie is never checked against list_projects() or any allow-list. load_config
accepts absolute paths verbatim (config.py:156-158) with no containment check. And the
if not cfg.data_dir.exists(): return 404 guard at routes.py:525 is unreachable dead
code, because load_config already created the directory at config.py:165.
list_projects() (config.py:20-47) is a directory scan of the entire repository root —
the same directory holding umbraaudio/, scripts/, tests/, models/, .venv/ — and it
opens a SQLite connection to every candidate on every page render.
B.6 Operations that must continue during migration
Uninterrupted: upload/parse, discovery, profiling, voice-reference generation, LLM attribution, speaker/alias/merge editing, name-token resolution, pronunciation editing, utterance audio generation, chapter build + stitch, full-book generation, m4b and AR MP3 export, QA checks.
Explicitly not a prerequisite: the translation/review UI. The audio worker must reach a usable state with the UI absent or offline.
B.7 Authority classification of local data
| Data | Class | Note |
|---|---|---|
chapters.source_text, utterances.text |
Authoritative | Full copyrighted manuscript text |
speakers.json, voice_profiles.json, voice_assignments.json, characters |
Authoritative, 4-way conflict | Must be reconciled in Phase P3 |
voices/*_ref.wav, ~/.umbra/library/voices/, voice_embeddings/*.npy, voice_prompts/*.pt |
Authoritative + biometric | See §C.2 |
*.wav, *.mp3, *.m4b |
Authoritative artifacts, unversioned | No checksums |
*.wav.txt markers |
Authoritative idempotency state | Must be preserved verbatim across cutover |
manifests/ch{N}.json |
Derived from SQLite | |
pipeline_status.json, pipeline_checkpoint.json, analysis_status.json, ar_build_status.json, scan_results.json |
Cache/progress | 4 of 5 written non-atomically |
generation_status.json |
Cache/progress, atomic | The one correct pattern (generation_manager.py:52-55) |
ch{N}_build_status.json |
Cache/progress, non-atomic | Rewritten ~2× per utterance (chapter_builder.py:503) |
defects.json (1.3 MB), debug_*.json |
Derived/debug | Contains LLM reasoning |
discovery_pass*.json |
Temporary | Inputs to speakers.json |
cache/cache_index.json, voice_analysis.json |
Cache |
C. Current-state persistence and identity inventory
C.1 Legacy objects (full field detail in §F rationale; summary here)
| # | Logical entity | Storage | Owner | Primary identifier | Lifecycle | Class | Sensitivity |
|---|---|---|---|---|---|---|---|
| 1 | Project | <base>/<slug>/ + SQLite project |
audio app | filesystem dir name (slug); project.id INTEGER |
initialized |
Authoritative | Copyrighted |
| 2 | Source manuscript | uploads/, last_upload/, project.source_path |
audio app | filesystem path | replaced in place | Authoritative | Copyrighted + licensed |
| 3 | Chapter | SQLite chapters |
audio app | chapters.id INTEGER (per-DB) |
parsed |
Authoritative | Copyrighted |
| 4 | Utterance | SQLite utterances (26 cols) |
audio app | utterances.id INTEGER; no UNIQUE on (chapter_id, utterance_index) |
pending |
Authoritative | Copyrighted derivative |
| 5 | Speaker | 5 stores (see §B.2) | audio app | 4 different keys | identified / unidentified |
Authoritative, conflicting | Personal (voice) |
| 6 | Voice review | SQLite voice_review_history (no project_id) |
audio app | id INTEGER |
append | Authoritative | Personal |
| 7 | Pronunciation term | 3 stores (pronunciation_guide, phonetic_names.json, library_names) |
audio app | term TEXT; UNIQUE (project_id, term) on one only |
confirmed |
Authoritative | Low |
| 8 | Generated audio | SQLite generated_audio + filesystem |
audio app | id INTEGER |
generated |
Table not written by the pipeline; filesystem is truth | — |
| 9 | Quality report | SQLite quality_reports |
audio app | id INTEGER |
— | Table exists, no row is ever written (database.py:549 is the only caller) |
— |
| 10 | Review decision | SQLite review_decisions (polymorphic entity_type/entity_id, no FK) |
audio app | id INTEGER |
— | Authoritative | Low |
| 11 | Attribution exception | SQLite exception_reviews (web-only table, absent from database.py) |
audio web | id INTEGER |
pending/resolved |
Authoritative | Low |
| 12 | Orphan name token | SQLite name_tokens; UNIQUE (project_id, token) |
audio app | token TEXT |
unresolved |
Authoritative | Low |
| 13 | Voice reference | voices/{slug}_ref.wav, ~/.umbra/library/voices/ |
audio app | filename slug | exists/absent | Authoritative | Biometric + consent |
| 14 | Voice embedding | voice_embeddings/{slug}.npy |
audio app | filename slug | — | Cache | Biometric |
| 15 | Qwen voice prompt | voice_prompts/{slug}.pt (server-side) |
Qwen TTS server | slug | — | Cache | Biometric |
| 16 | Pipeline checkpoint | pipeline_status.json, pipeline_checkpoint.json |
audio app | none | step state | Cache | Low |
| 17 | Generation run | generation_status.json, ch{N}_build_status.json |
audio app | none | active bool / phase |
Cache | Low |
| 18 | Artifact manifest | export/ar_package.json |
audio app | — | rebuilt wholesale | Authoritative for shipped package | Low |
| 19 | Job | DOES NOT EXIST | — | — | — | — | — |
| 20 | User / session / role | DOES NOT EXIST | — | — | — | — | — |
C.2 Data classification — the two sensitive classes
Class R (Restricted — biometric + consent). voices/*_ref.wav,
voice_embeddings/*.npy, voice_prompts/*.pt, ~/.umbra/library/voices/. These encode a
human or cloned vocal identity. Where a real person's voice was cloned, EU/UK AI and biometric
rules and the provider's own consent terms apply. No retention period, no consent-evidence
record, no territory or media restriction, and no takedown mechanism exists today. These must
never appear in PostgreSQL; only keys, checksums and rights metadata do.
Class C (Confidential — copyrighted). chapters.source_text, utterances.text,
utterances.context_before/after, uploads/, all produced audio and exports, defects.json.
Underlying the author's licence. Translation derivatives inherit this class and must not
outlive the source entitlement.
Class P (Personal). users.email, sessions.ip_address, sessions.user_agent,
audit_events.ip_address, plus the legacy 'user' actor strings.
C.3 Verified schema divergence (a migration hazard in its own right)
There are four independent DDL authorities for the same project database:
umbraaudio/database.py:9-166— 10 tables (SCHEMA).umbraaudio/web/routes.py:102-275— 11 tables (SCHEMA_SQL); addsexception_reviews+ 3 indexes + 3qa_*columns onutterances.umbraaudio/analysis/name_tokens.py:504-520—name_tokensonly, no indexes.scripts/reattribute_all.py:30-42—ALTER TABLEonly.
Both live project databases have been built by ALTER TABLE under older schemas, so column
ordering differs from both schema files; DealHunterShip lacks name_tokens entirely; and
neither database has PRAGMA foreign_keys=ON, so every REFERENCES clause in the legacy
schema is declarative only. There are zero CHECK constraints in either legacy schema file.
D. Enterprise identity, session, and project-access contract
D.1 Canonical model
The enterprise users, organizations, projects, roles, permissions and user_roles
records are the canonical account and project model. No second password store is created
(verified: users has no password column, and users_auth_subject_idx is a partial unique
index on (auth_provider, auth_subject)). No second project namespace is created.
Membership is not a new table. user_roles with scope='project' is the membership
record. "Active membership" ≡ user_roles.scope='project' AND user_roles.status='active' AND users.status='active'. This is deliberate: a separate project_members table would create two
sources of truth for the same fact. I reject the project_members and
project_access_grants models from spec 19 §8.1 for phase one.
D.2 Login flow
- Unauthenticated request →
302to the enterprise IdP (OIDC authorization-code + PKCE). - IdP callback → the API verifies the ID token signature, issuer, audience,
nonce,expandazpserver-side, and mapsiss+subtousers.auth_provider+users.auth_subject. Ausersrow is created on first login only if a matchinginvitationsrow exists and is unexpired, unrevoked and unaccepted. - If
users.mfa_requiredis true, the IdP must assert an MFAacr/amr value satisfying the policy; the API recordssessions.mfa_verified_at. Today no such column exists (§M B4). - The API issues a session: 32 random bytes, stored only as SHA-256 in
sessions.token_hash; the plaintext goes to the browser in anHttpOnly; Secure; SameSite=Laxcookie. sessions.ip_addressanduser_agentrecorded.- Bootstrap path (first run only):
bootstrap_tokens.token_hash→ set password/MFA at the IdP →consumed_at. The bootstrap token is single-use and 24 h (bootstrap script:301-302).
The single authentication entry point is one SECURITY DEFINER function,
app.resolve_session(p_token_hash text), returning the user id only if all of: hash match,
revoked_at IS NULL, expires_at > now(), users.status='active', organizations.status is
active for the requested org, and MFA satisfied when required. Every other code path
authorises from the returned principal, never from a GUC or a request value.
D.3 Session lifetime and revocation
- Idle timeout 8 h, absolute 7 days, both stored in
sessions.expires_at(absolute) plus an application-side idle clock. - Logout:
UPDATE sessions SET revoked_at = now()for the presented token. - "Sign out everywhere": requires new capability —
sessions_selfis the only policy (001:526-529) and isUSING (user_id = app.current_user_id()), so a user cannot revoke their own other sessions and an admin cannot revoke anyone's. Fix:app.revoke_user_sessions(uuid)asSECURITY DEFINER, executable by the owning user and by an org admin, writingaudit_events. - Suspension is enforced in
app.resolve_session, not by RLS, because no RLS policy readsusers.status. A suspended user's existing session stops resolving on the next request; the row remains for forensics. This is the accepted design, with the caveat recorded in §M B5.
D.4 Project selection
Selection is server-derived, never client-declared. The request path carries an opaque
project_id (uuid). The API resolves organization_id from projects and validates
app.can_access_project(project_id) on every request before any domain read. The browser
may hold a selected project hint; it is a signed, server-issued value and is advisory only
— a stale or forged value cannot widen access, because access is recomputed from the
authorised-project set each time. The existing unsigned active_project cookie is retired.
D.5 Authorisation checks by action
Every action maps to a permissions.key. Existing keys are listed in 001:378-391; the keys
missing and required by the prompt are enumerated in §H as ADD TABLE (data) / BACKFILL.
| Action | Required permission | Role(s) granted | Exists in 001? |
|---|---|---|---|
| View project metadata | project.access.view (via membership) |
any project role | No — implicit only |
| Read manuscripts/utterances | project.source.read |
all project roles | No |
| Edit speakers/aliases/merges | project.speakers.edit |
project_manager, audio_director |
No — destructive today, unpermissioned |
| Run analysis / attribution | project.analysis.run |
project_manager, audio_director |
No |
| Review translations | project.translation.review |
proofreader |
Yes |
| Edit translations | project.translation.edit |
translator, project_manager |
Yes |
| Generate audio | project.audio.generate |
project_manager, audio_director |
No — no generate permission exists |
| Approve audio | project.audio.approve |
audio_director, project_manager |
Yes |
| Cast voices | project.voice.cast |
audio_director |
No |
| Manage voice library/licences | voice.catalog.manage |
voice_manager |
Yes (but see §J.3 — cannot see a project) |
| Download approved artifact | artifact.download |
member, viewer |
Yes |
| Download unapproved/unreleased | artifact.download.unreleased |
audio_director, release_manager |
No — and today all downloads are ungated |
| Approve release | project.release.approve |
release_manager, project_owner |
Yes |
| Manage project access | project.access.manage |
project_owner, organization_admin |
Yes |
| Delete/reset project state | project.state.destroy |
project_owner |
No — rmtree is unpermissioned today |
| Manage own sessions/MFA | user.self.manage |
self | No |
D.6 Suspended / deactivated behaviour
users.status ∈ invited | active | suspended | deactivated | locked. In every non-active
state: existing sessions stop resolving, new logins are refused, background jobs owned by that
user are cancelled, and historical attribution and audit rows are preserved — suspension
never rewrites history. organizations.status ∈ active | suspended | archived gates all
project access for members of that org.
D.7 Service-to-service authentication for the audio worker
The worker is never a users row and never impersonates one. It authenticates as the
umbracontrol_worker PostgreSQL role, and presents a service identity (service_identities,
§E) plus a lease (job_leases). RLS policy branches for the worker are keyed on
app.current_service_identity_id() and the existence of a live lease joining that service
identity to the row's project_id. A worker can therefore read and write exactly the projects
it is currently engaged on, and nothing else. A worker with no lease sees zero rows. This
satisfies "a worker maps to a project without impersonating a user" and "a worker cannot read a
project outside its lease" (spec 19 §5.5).
D.8 Account and project segmentation rules
organization_idis the tenant boundary; present on every tenant-owned row.project_idscopes work; a user reaches a project only through an activeuser_rolesproject-scope row, or org-admin, or platform-admin.- A user in multiple organizations holds separate
user_rolesrows per org; there is no cross-org inheritance and no "default org" that grants implicit access. - Sessions are user-global, not org-scoped. A session token is therefore valid for every org that user belongs to — correct for a human, and the reason project/org context must be re-derived and re-authorised on every request rather than cached in the session.
- Voice assets, licences and entitlements are org-scoped and reusable across that org's projects only, and only when language, accent, consent, territory, media and term allow.
E. Proposed canonical entity model
Accept / reject decisions on the models the prompt asked me to evaluate.
| # | Proposed model | Decision | Rationale |
|---|---|---|---|
| 1 | Source books / editions / source documents → works + source_documents |
ACCEPT | A public-domain edition and an author manuscript are different assets of one bibliographic work (spec 19 §6.1). UmbraAudio's project.source_path becomes a source_documents row. |
| 2 | Immutable analysis runs + snapshots → analysis_runs + analysis_snapshots |
ACCEPT | Mandatory for invariant "analysis has immutable versions". Snapshots are content-addressed, never updated. |
| 3 | Speakers / characters → speakers + speaker_aliases |
ACCEPT | Resolves the 5-store/4-key defect (§B.2). One row per logical speaking entity per project. |
| 4 | Canonical voice profiles → voice_profiles + voice_profile_revisions |
ACCEPT | Spec 19 §11.1: logical identity ≠ provider asset ≠ profile. |
| 5 | Voice casting and reuse → voice_casts |
ACCEPT | Casting is a fact about (project, locale, speaker, revision) and must be snapshot-able. |
| 6 | Voice licensing / consent / usage → voice_licenses |
ACCEPT (phase 1: 1 table) | Rights are the gating fact for reuse. voice_entitlements, voice_reservations, voice_rate_cards, voice_invoices are DEFERRED to a later phase — no commercial billing is in scope for this transition. |
| 7 | Translation runs + language targets → project_locales + translation_runs |
ACCEPT | project_locales is the locale lane; no translations_de-style tables. |
| 8 | Translation segments / units → translation_units |
ACCEPT | Grain = one ordered target render span. |
| 9 | Audio generation runs + voice-cast snapshot → audio_runs + audio_run_casts |
ACCEPT | The cast snapshot must be frozen per run so a release can be reproduced after casting changes. |
| 10 | Generated ebook / audiobook artifacts → artifacts |
ACCEPT | Metadata only; bytes in object storage. |
| 11 | Pipeline jobs, retries, idempotency → jobs + job_attempts + job_leases |
ACCEPT | Closes the "no queue, filename is identity" defect. |
| 12 | Provenance, lineage, checksums, audit → artifact_links + reuse audit_events |
ACCEPT | audit_events already exists and is append-only by trigger (001:356-359). |
| 13 | project_members (separate table) |
REJECT | Membership is user_roles(scope='project'). A second table is a second source of truth. |
| 14 | project_access_grants (spec 19 §8.1) |
REJECT for phase 1 | No non-user principals exist yet. Revisit when a translation-vendor principal type is needed. |
| 15 | works ↔ projects 1:1 |
REJECT — use 1:N | A work may be produced in several projects (re-translation, new edition). projects.work_id FK. |
| 16 | semantic_packages, identity_registries, glossary_revisions, memory_revisions, alignment_links, target_render_spans (spec 19 §8.2) |
DEFER | These are UmbraTrans translation-design objects. They are required for the translation UI, not for the audio system to keep producing books. Adding them now would exceed the continuity objective. Listed in §M as a phase-2 scope item. |
| 17 | Storing WAV/MP3/M4B/EPUB bytes in PostgreSQL | REJECT — hard | Object storage only (§G invariant 8). |
| 18 | Storing voice reference audio or embeddings in PostgreSQL | REJECT — hard | Class R biometric data; object storage with rights metadata. |
E.1 The stable utterance identity (the load-bearing new concept)
Because artifacts are named by a mutable index, the enterprise model introduces a content-addressed unit key and records it at generation time, without changing any on-disk filename:
source_unit_key = sha256(
project_id ‖ source_revision_id ‖
chapter_ordinal ‖ occurrence_index ‖
text_sha256
)
source_units is keyed by source_unit_key. artifacts reference (source_revision_id, unit_ordinal, text_sha256) and record the legacy path in legacy_object_key. Consequences:
- A re-segmentation is detectable: the same ordinal now has a different
text_sha256, so the enterprise record and the on-disk file diverge visibly instead of silently. - The existing
*.wav.txtmarker logic (generation/staleness.py) keeps working unchanged, so continuity is preserved and the audio path is not blocked on this migration. - UmbraTrans can reference English units with a durable key, satisfying spec 18 §3.3, which
forbids using a mutable
utterance_indexor a database row id as a cross-language ID.
F. Table-by-table contract
Common conventions for every table below:
- PK:
id uuid PRIMARY KEY DEFAULT gen_random_uuid()(v4, matching001exactly — no version dependency on the server's PostgreSQL version). - Tenancy:
organization_id uuid NOT NULL,project_id uuid NOT NULL(nullable only for org-scoped tables), enforced byFOREIGN KEY (project_id, organization_id) REFERENCES projects(id, organization_id). This composite FK is possible because001:51already declaresUNIQUE (id, organization_id)onprojects— the schema anticipated this pattern. - Concurrency:
row_version integer NOT NULL DEFAULT 1, incremented by trigger, with optimisticUPDATE ... WHERE id = ? AND row_version = ?. Job claiming additionally usesFOR UPDATE SKIP LOCKED. - RLS:
ENABLE ROW LEVEL SECURITY(neverFORCEon the mutually-referencing tables — see §J.2), plus atenant_read/tenant_writepolicy pair using the composite FK columns. - Idempotency: natural unique constraints stated per table; all writes are
INSERT … ON CONFLICTor guarded by a uniqueness violation, never blind overwrites. - New / extension / not-in-PostgreSQL stated per table.
F.1 works
- Purpose: bibliographic identity of a book, independent of any production project.
- Grain: one row per distinct work.
- Authoritative writer: ingestion service (human via API).
- Columns:
id uuid PK;organization_id uuid NOT NULL;title text NOT NULL;author text;series text;isbn13 text;source_language text;rights_status text NOT NULL DEFAULT 'unknown';rights_evidence_uri text;created_at,updated_at timestamptz NOT NULL DEFAULT now(). - FKs:
organization_id → organizations(id) ON DELETE RESTRICT. - Unique:
UNIQUE (organization_id, isbn13)whereisbn13 IS NOT NULL. - Indexes:
works_org_idx (organization_id);works_title_trgm(pg_trgm) later. - State machine:
active → archived. No separate status column needed in phase 1; reusearchived_at timestamptz NULL. - RLS: org-scoped read for members; write requires
organization.projects.manage. - Classification: C (title/author are bibliographic, not licensed text).
- Idempotency/concurrency:
UNIQUE (organization_id, isbn13);row_version. - Verdict: NEW.
F.2 source_documents
- Purpose: an immutable ingested file (DOCX/EPUB/TXT/PDF) of a work.
- Grain: one row per distinct ingested file version.
- Authoritative writer: ingestion service.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;work_id uuid NOT NULL → works(id) ON DELETE RESTRICT;media_type text NOT NULL;byte_size bigint NOT NULL CHECK (byte_size > 0);sha256 char(64) NOT NULL;object_key text NOT NULL;original _filename text;ingested_by_user_id uuid → users(id) ON DELETE SET NULL;ingested_at timestamptz NOT NULL DEFAULT now();superseded_at timestamptz;row_version integer NOT NULL DEFAULT 1. - Unique:
UNIQUE (project_id, sha256)— re-ingesting the same bytes is a no-op. - Indexes:
source_documents_project_idx (project_id, ingested_at DESC);source_documents_work_idx (work_id). - State machine:
active → superseded → withdrawn. - RLS: project tenant policy;
WITH CHECKalso requires the actor to holdproject.source.edit. - Classification: C.
- Idempotency: content-addressed by
sha256; re-upload returns the existing row. - Verdict: NEW. Backfilled from
project.source_pathanduploads/.
F.3 source_revisions
- Purpose: a frozen, numbered revision of the source text — the anchor for all analysis, translation and audio lineage.
- Grain: one row per frozen source-text revision.
- Authoritative writer: source-editor workflow.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;work_id uuid NOT NULL;source_document_id uuid NOT NULL → source_documents(id) ON DELETE RESTRICT;revision_number integer NOT NULL;label text;sha256 char(64) NOT NULL;object_key text NOT NULL;word_count integer;locked boolean NOT NULL DEFAULT false;locked_by_user_id uuid;locked_at timestamptz;approved_by_user_id uuid;approved_at timestamptz;created_at timestamptz NOT NULL DEFAULT now();row_version integer NOT NULL DEFAULT 1. - FKs:
locked_by_user_id/approved_by_user_id → users(id) ON DELETE SET NULL(history must survive account deletion). - Unique:
UNIQUE (project_id, revision_number);UNIQUE (id, project_id, organization_id)to enable child composite FKs. - Indexes:
source_revisions_project_idx (project_id, revision_number DESC). - State machine:
draft → locked → approved(immutable afterlocked; enforced by aBEFORE UPDATEtrigger raising whenOLD.locked), ordraft → abandoned. - RLS: tenant policy; write requires
project.source.edit. - Classification: C.
- Idempotency:
UNIQUE (project_id, revision_number); contentsha256recorded. - Verdict: NEW.
F.4 chapters (enterprise)
- Purpose: chapter boundaries of a source revision.
- Grain: one row per chapter per source revision.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;source_revision_id uuid NOT NULL;ordinal integer NOT NULL;title text;text_sha256 char(64) NOT NULL;word_count integer NOT NULL DEFAULT 0;created_at;row_version. - Unique:
UNIQUE (source_revision_id, ordinal). - Indexes:
chapters_project_idx (project_id, ordinal). - State machine: none; immutable with its source revision.
- RLS: tenant read; write requires
project.source.edit. - Idempotency:
ON CONFLICT (source_revision_id, ordinal) DO UPDATE. - Verdict: NEW (distinct from legacy SQLite
chapters; name is the same by intent, but the enterprise table is uuid-keyed and revision-scoped). To avoid confusion the enterprise table is namedsource_chapters.
F.5 source_units
- Purpose: the atomic addressable source element — the durable unit that audio clips, translation units and review items all reference.
- Grain: one row per addressable source text unit within a source revision.
- Authoritative writer: analysis/segmentation pipeline.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;source_revision_id uuid NOT NULL;source_chapter_id uuid NOT NULL;ordinal integer NOT NULL;occurrence integer NOT NULL DEFAULT 0;text text NOT NULL;text_sha256 char(64) NOT NULL;unit_kind text NOT NULL DEFAULT 'utterance' CHECK (unit_kind IN ('narration','utterance', 'heading','scene_break','footnote'));speaker_name text;speaker_id uuid → speakers(id) ON DELETE SET NULL;attribution_confidence numeric(4,3);attribution_evidence_kind text;attribution_tag_name text;attribution_uncertain boolean NOT NULL DEFAULT false;needs_review boolean NOT NULL DEFAULT false;review_reason text;user_edited boolean NOT NULL DEFAULT false;created_at;row_version. - Unique:
UNIQUE (source_revision_id, ordinal, occurrence); plus a generatedsource_unit_key char(64)=sha256(source_revision_id || ordinal || occurrence || text_sha256),UNIQUE. See §E.1. - Indexes:
source_units_chapter_idx (source_chapter_id, ordinal);source_units_speaker_idx (project_id, speaker_id);source_units_review_idx (project_id, needs_review) WHERE needs_review;source_units_sha_idx (source_revision_id, text_sha256). - State machine:
proposed → attributed → reviewed;needs_reviewis an overlay flag, not a state. - RLS: tenant read; write requires
project.analysis.runorproject.speakers.edit. - Classification: C.
- Idempotency: upsert on
(source_revision_id, ordinal, occurrence); text change bumpsrow_versionand flipsneeds_review. - Verdict: NEW. This is the enterprise replacement for legacy SQLite
utterances.
F.6 speakers
- Purpose: the logical speaking entity — the stable cross-language character identity.
- Grain: one row per logical speaker per project.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;canonical_name text NOT NULL;kind text NOT NULL DEFAULT 'character' CHECK (kind IN ('character','narrator', 'group','system','unknown'));gender text;age_range text;description text;communication_style text;is_group boolean NOT NULL DEFAULT false;is_unidentified boolean NOT NULL DEFAULT false;unidentified_reason text;designation text NOT NULL DEFAULT 'needs_review' CHECK (designation IN ('clear','needs_review'));sample _dialogue text;source text NOT NULL DEFAULT 'analysis'CHECK (source IN ('analysis', 'manual','imported','legacy'));created_at;updated_at;row_version integer NOT NULL DEFAULT 1. - Unique:
UNIQUE (project_id, canonical_name). - Indexes:
speakers_project_idx (project_id, designation);speakers_name_trgm (project_id, canonical_name gin_trgm_ops)(pg_trgm, phase 2). - State machine:
discovered → profiled → confirmed; merge is asuperseded_bypointer, never a delete. - RLS: tenant read; write requires
project.speakers.edit. - Classification: P (name + gender + description).
- Idempotency:
ON CONFLICT (project_id, canonical_name) DO UPDATE; merges areUPDATE speakers SET superseded_by = target+ alias fold, both in one transaction. - Verdict: NEW. Replaces
characters+speakers.json+voice_profiles.jsonas the single speaker authority.
F.7 speaker_aliases
- Purpose: alternate surface forms and the evidence for each, preserving today's
aliases/alias_evidencemodel. - Grain: one row per (speaker, alias form).
- Columns:
id uuid PK;organization_id,project_idNOT NULL;speaker_id uuid NOT NULL → speakers(id) ON DELETE CASCADE;alias citext NOT NULL;evidence jsonb NOT NULL DEFAULT '{}';confidence numeric(4,3);is_confirmed boolean NOT NULL DEFAULT false;created_at;updated_at. - Unique:
UNIQUE (project_id, alias). - Indexes:
speaker_aliases_alias_idx (project_id, alias);speaker_aliases_speaker_idx (speaker_id). - State machine: none;
is_confirmedis a flag. - RLS: tenant; write requires
project.speakers.edit. - Idempotency:
ON CONFLICT (project_id, alias) DO NOTHINGthen merge evidence. - Verdict: NEW.
F.8 analysis_runs
- Purpose: an immutable record of one analysis execution (which model, which prompt version, which inputs, which parameters).
- Grain: one row per analysis execution.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;source_revision_id uuid NOT NULL;run_kind text NOT NULLCHECK (discovery,segmentation,attribution,emotion,voice_analysis);engine text NOT NULL;model_id text NOT NULL;prompt_version text NOT NULL;input_sha256 char(64) NOT NULL;params jsonb NOT NULL DEFAULT '{}';status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'succeeded','failed','cancelled','superseded'));started_by_service_id uuid;started_at timestamptz NOT NULL DEFAULT now();finished_at timestamptz;metrics jsonb NOT NULL DEFAULT '{}';error text;superseded_by_run_id uuid → analysis_runs(id) ON DELETE SET NULL. - Unique:
UNIQUE (project_id, run_kind, source_revision_id, input_sha256, prompt_version, model_id)— re-running an identical analysis is a no-op, which is the idempotency requirement. - Indexes:
analysis_runs_project_idx (project_id, run_kind, started_at DESC). - State machine:
running → succeeded | failed | cancelled;succeeded → superseded. - RLS: tenant read; write: worker lease-scoped, human
project.analysis.run. - Classification: C (params may embed text).
- Idempotency/concurrency: the wide UNIQUE;
FOR UPDATEon transition. - Verdict: NEW.
F.9 analysis_snapshots
- Purpose: the immutable result payload of an analysis run, content-addressed.
- Grain: one row per produced snapshot.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;analysis_run_id uuid NOT NULL → analysis_runs(id) ON DELETE RESTRICT;snapshot_kind text NOT NULL;sha256 char(64) NOT NULL;object_key text;inline_payload jsonb;byte_size bigint;created_at. - Unique:
UNIQUE (project_id, snapshot_kind, sha256);UNIQUE (analysis_run_id, snapshot_kind). - Indexes:
analysis_snapshots_run_idx (analysis_run_id). - State machine: immutable, no updates.
- RLS: tenant read; write only by the producing worker under lease.
- Classification: C.
- Idempotency: content-addressed.
- Verdict: NEW. Satisfies "analysis has immutable versions".
F.10 voice_profiles
- Purpose: a reusable voice identity profile (spec 19 §11.1) — the logical reusable character/performance identity, not a provider asset.
- Grain: one row per voice profile per organization.
- Columns:
id uuid PK;organization_id uuid NOT NULL;project_id uuid NULL(null = org-shared);name text NOT NULL;locale_tag text;gender text;accent text;pitch_register,pace_baseline,energy,timbre,texture,formality,emotional _baseline,accent_marker,distinguishing_trait(alltext);voice_description text;reference_object_key text;reference_sha256 char(64);engine text NOT NULL DEFAULT 'qwen3-tts';engine_voice_id text;is_narrator boolean NOT NULL DEFAULT false;status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','in_review','approved', 'rejected','retired'));created_at;updated_at;row_version integer NOT NULL DEFAULT 1. - Unique:
UNIQUE (organization_id, name). - Indexes:
voice_profiles_org_idx (organization_id, status). - State machine:
draft → in_review → approved | rejected;approved → retired. - RLS: org policy — any org member may read metadata;
reference_object_keyaccess requiresvoice.catalog.manageor an active cast. - Classification: P; the reference audio is Class R and never in PostgreSQL.
- Idempotency: upsert on
(organization_id, name); profile changes createvoice_profile_revisions. - Verdict: NEW. Replaces
voice_registry+library_speakers+voice_profiles.json.
F.11 voice_profile_revisions
- Purpose: immutable history of a voice profile so a past render stays reproducible.
- Grain: one row per profile change.
- Columns:
id uuid PK;organization_id uuid NOT NULL;voice_profile_id uuid NOT NULL → voice_profiles(id) ON DELETE CASCADE;revision_number integer NOT NULL;payload jsonb NOT NULL;sha256 char(64) NOT NULL;changed_by_user_id uuid;change_reason text;created_at timestamptz NOT NULL DEFAULT now(). - Unique:
UNIQUE (voice_profile_id, revision_number). - Indexes:
voice_profile_revisions_profile_idx (voice_profile_id, revision_number DESC). - State machine: immutable.
- RLS: org read; write requires
voice.catalog.manage. - Idempotency:
UNIQUE (voice_profile_id, revision_number);UNIQUE (voice_profile_id, sha256). - Verdict: NEW.
F.12 voice_licenses
- Purpose: the rights record that gates reuse of a voice asset across projects, locales, media and time.
- Grain: one row per licence grant for a voice profile.
- Columns:
id uuid PK;organization_id uuid NOT NULL;voice_profile_id uuid NOT NULL → voice_profiles(id) ON DELETE RESTRICT;license_type text NOT NULL;licensor text;territories text[] NOT NULL DEFAULT '{}';media text[] NOT NULL DEFAULT '{}';languages text[] NOT NULL DEFAULT '{}';exclusive boolean NOT NULL DEFAULT false;term_start date NOT NULL;term_end date;max_concurrency integer;derivative_rights boolean NOT NULL DEFAULT false;consent_evidence_uri text;consent_recorded_at timestamptz;status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','expired','revoked', 'takedown'));revoked_at,revoked_reason text;created_at,updated_at;row_version. - Unique:
UNIQUE (voice_profile_id, term_start). - Indexes:
voice_licenses_profile_idx (voice_profile_id, status);voice_licenses_expiry_idx (organization_id, term_end) WHERE status = 'active'— supports the "assets expiring before release" dashboard. - State machine:
pending → active → expired | revoked | takedown. - RLS: org read for
voice_manager; write requiresvoice.catalog.manage. A cast may read onlystatus='active'rows covering the requested locale/territory/media/date — enforced byapp.voice_licence_covers(voice_profile_id, locale, territory, media, at_date). - Classification: P + legal.
consent_evidence_uripoints to object storage, never inline. - Idempotency: upsert on
(voice_profile_id, term_start). - Verdict: NEW. Backfilled as
status='pending'withrights_status='unknown'— UmbraAudio holds no licence evidence today, so every legacy voice is non-reusable for paid/public release until a voice manager approves it. This is a deliberate, conservative default.
F.13 voice_casts
- Purpose: assignment of a speaker to a voice profile for a project/locale/revision, and the snapshot input to an audio run.
- Grain: one row per (project, locale, speaker, source revision, revision_number).
- Columns:
id uuid PK;organization_id,project_idNOT NULL;project_locale_id uuid;speaker_id uuid NOT NULL → speakers(id) ON DELETE RESTRICT;voice_profile_id uuid NOT NULL → voice_profiles(id) ON DELETE RESTRICT;source_revision_id uuid NOT NULL;revision_number integer NOT NULL DEFAULT 1;voice_licence_id uuid → voice_licenses(id) ON DELETE RESTRICT;cast_by_user_id uuid;cast_at timestamptz NOT NULL DEFAULT now();row_version. - Unique:
UNIQUE (project_id, speaker_id, source_revision_id, revision_number). - Indexes:
voice_casts_locale_idx (project_locale_id);voice_casts_profile_idx (voice_profile_id)— reverse-lookup "where is this voice used". - State machine:
proposed → approved → superseded. - RLS: tenant read; write requires
project.voice.cast. Deny-by-default trigger: a cast whosevoice_licence_idis notactiveand covering the locale/date is rejected. - Classification: P.
- Idempotency: upsert on the 4-column unique; a change increments
revision_numberand supersedes the prior row (never updates in place). - Verdict: NEW. Replaces
voice_assignments.jsonandcharacters.voice_description.
F.14 project_locales
- Purpose: the target-language lane for a project. No
translations_detables. - Grain: one row per (project, BCP-47 locale).
- Columns:
id uuid PK;organization_id,project_idNOT NULL;locale_tag text NOT NULL;is_source boolean NOT NULL DEFAULT false;translation_status text NOT NULL DEFAULT 'not_started';proofread_status text NOT NULL DEFAULT 'not_started';cast_status text NOT NULL DEFAULT 'not_started';audio_status text NOT NULL DEFAULT 'not_started';qa_status text NOT NULL DEFAULT 'pending';release_state text NOT NULL DEFAULT 'draft';current_ translation_run_id uuid;target_voice_profile_set_hash char(64);row_version integer NOT NULL DEFAULT 1;created_at,updated_at. - Unique:
UNIQUE (project_id, locale_tag). - Indexes:
project_locales_project_idx (project_id). - State machine: per-column monotone ladders (
not_started → in_progress → in_review → approved),release_state:draft → approved → released | withdrawn. - RLS: tenant read; write requires the matching role for the column
(
project.translation.edit,project.translation.review,project.voice.cast,project.audio.generate,project.release.approve). - Idempotency: upsert on
(project_id, locale_tag). - Verdict: NEW. This is the one row per language that replaces all per-language tables.
F.15 translation_runs
- Purpose: an immutable execution record for translating a source revision into a locale.
- Grain: one row per translation execution.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;project_locale_id uuid NOT NULL;source_revision_id uuid NOT NULL;run_kind text NOT NULL DEFAULT 'draft'(draft,revise,verify);engine text NOT NULL;model_id text NOT NULL;prompt_version text NOT NULL;input_sha256 char(64) NOT NULL;status text NOT NULL DEFAULT 'running'(same enum asanalysis_runs);started_by_service_id uuid;started_at,finished_at;metrics jsonb NOT NULL DEFAULT '{}';error text;superseded_by_run_id uuid. - Unique:
UNIQUE (project_locale_id, run_kind, source_revision_id, input_sha256, prompt_version, model_id). - Indexes:
translation_runs_locale_idx (project_locale_id, started_at DESC). - State machine:
running → succeeded | failed | cancelled;succeeded → superseded. - RLS: tenant read; write:
project.translation.edit(human) or worker under lease. - Idempotency: the wide UNIQUE; a retry with identical inputs returns the existing row.
- Verdict: NEW.
F.16 translation_units
- Purpose: an ordered target render span for one source unit in one locale.
- Grain: one row per (translation run, source unit, ordinal).
- Columns:
id uuid PK;organization_id,project_idNOT NULL;translation_run_id uuid NOT NULL → translation_runs(id) ON DELETE CASCADE;source_unit_id uuid NOT NULL → source_units(id) ON DELETE RESTRICT;source_unit_key char(64) NOT NULL(denormalised so cross-language joins never need a chain);ordinal integer NOT NULL;target_text text NOT NULL;target_sha256 char(64) NOT NULL;status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','submitted','proofread','approved','rejected'));proofreader_user_id uuid;reviewed_at timestamptz;review_notes text;row_version integer NOT NULL DEFAULT 1;created_at,updated_at. - Unique:
UNIQUE (translation_run_id, ordinal);UNIQUE (translation_run_id, source_unit_id). - Indexes:
translation_units_run_idx (translation_run_id, ordinal);translation_units_source_idx (project_id, source_unit_key)— the cross-language join path. - State machine:
draft → submitted → proofread → approved;→ rejectedfromsubmitted/proofread. Enforced by a trigger onstatustransitions. - RLS: tenant read;
draft→submittedrequiresproject.translation.edit;→ approvedrequiresproject.translation.reviewandreviewer ≠ author(four-eyes, implemented as a trigger comparingrow_version-tracked author field). - Classification: C.
- Idempotency: upsert;
row_versiongives two reviewers a conflict instead of a silent overwrite. - Verdict: NEW.
F.17 audio_runs
- Purpose: one generation run over a project (or a bounded chapter range within it).
- Grain: one row per generation execution.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;project_locale_id uuid NULL(null = source language);source_revision_id uuid NOT NULL;translation_run_id uuid NULL;run_scope text NOT NULL DEFAULT 'full' CHECK (run_scope IN ('full','chapter','unit', 'range'));scope_from integer,scope_to integer;cast_snapshot_sha256 char(64) NOT NULL;engine text NOT NULL;model_id text NOT NULL;params jsonb NOT NULL DEFAULT '{}';status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','leased','running', 'succeeded','partial','failed','cancelled'));units_total integer NOT NULL DEFAULT 0;units_generated integer NOT NULL DEFAULT 0;units_skipped integer NOT NULL DEFAULT 0;units_failed integer NOT NULL DEFAULT 0;started_by_service_id uuid;started_at,finished_at;error text;row_version integer NOT NULL DEFAULT 1. - Unique:
UNIQUE (project_id, source_revision_id, cast_snapshot_sha256, input_sha256, engine, model_id, run_scope, scope_from, scope_to)whereinput_sha256is a new column holding the sha256 of the ordered(unit_key, text_sha256, voice_profile_id, instruct, fx_version)tuple list. This is the enterprise form of the marker-based idempotency. - Indexes:
audio_runs_project_idx (project_id, started_at DESC);audio_runs_status_idx (status) WHERE status IN ('queued','leased','running'). - State machine:
queued → leased → running → succeeded | partial | failed | cancelled;leased → queuedon lease expiry (reaper). - RLS: tenant read; write: worker under lease, or human with
project.audio.generate. - Classification: C.
- Idempotency: the wide UNIQUE — a retried identical run is a no-op, matching today's
marker-skip behaviour exactly. Concurrency:
FOR UPDATE SKIP LOCKEDon claim. - Verdict: NEW.
F.18 audio_run_casts
- Purpose: the frozen cast snapshot an audio run actually used, so a release is reproducible after casting changes.
- Grain: one row per (audio run, speaker).
- Columns:
id uuid PK;organization_id,project_idNOT NULL;audio_run_id uuid NOT NULL → audio_runs(id) ON DELETE CASCADE;speaker_id uuid NOT NULL;voice_profile_id uuid NOT NULL;voice_licence_id uuid;voice_reference_sha256 char(64);cast_revision_number integer NOT NULL;created_at. - Unique:
UNIQUE (audio_run_id, speaker_id). - Indexes:
audio_run_casts_run_idx (audio_run_id);audio_run_casts_profile_idx (organization_id, voice_profile_id). - State machine: immutable.
- RLS: tenant read; write only by the run's lease holder.
- Idempotency:
ON CONFLICT DO NOTHING; the run-levelcast_snapshot_sha256is the digest of the ordered rows. - Verdict: NEW.
F.19 artifacts
- Purpose: the registry of every produced file. Metadata only.
- Grain: one row per immutable produced artifact version.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;project_locale_id uuid;artifact_kind text NOT NULL(source_clip,chapter_master,book_master,m4b,mp3_track,wav_master,cover,epub,pdf,qa_report,manifest,voice_reference,pronunciation_clip);logical_key text NOT NULL(e.g.ch11/utt_0042,ar_package/Deal_Hunter_01_Salvage.mp3);object_key text NOT NULL;sha256 char(64) NOT NULL;byte_size bigint NOT NULL CHECK (byte_size >= 0);media_type text NOT NULL;duration_ms integer;sample_rate integer;channels integer;loudness_lufs numeric(6,2);peak_dbfs numeric(6,2);source_revision_id uuid;translation_run_id uuid;audio_run_id uuid;supersedes_artifact_id uuid → artifacts(id) ON DELETE SET NULL;superseded_by_ artifact_id uuid;retention_class text NOT NULL DEFAULT 'standard';legal_hold boolean NOT NULL DEFAULT false;availability text NOT NULL DEFAULT 'available' CHECK (availability IN ('pending','quarantined','available','withdrawn'));produced_by_service_id uuid;created_at;row_version integer NOT NULL DEFAULT 1;legacy_object_key text;legacy_marker jsonb. - Unique:
UNIQUE (project_id, object_key);UNIQUE (project_id, sha256, artifact_kind). - Indexes:
artifacts_project_idx (project_id, artifact_kind, created_at DESC);artifacts_run_idx (audio_run_id);artifacts_source_unit_lookups via artifact_links. - State machine:
pending → quarantined | available;available → withdrawn. Never deleted whilelegal_holdor while referenced by a release manifest. - RLS: tenant read;
availability='available'additionally requires membership;withdrawnrequiresproject.release.approve; write via the artifact service only, and download via short-lived signed URLs audited inaudit_events. - Classification: metadata C/P; bytes never in PostgreSQL.
- Idempotency: content-addressed by
(project_id, sha256, artifact_kind); re-producing identical bytes returns the existing row and increments areproduced_count. - Verdict: NEW.
legacy_object_keyandlegacy_markerare how the existing on-disk files and their*.wav.txtmarkers are registered without moving a byte (§I).
F.20 artifact_links
- Purpose: the lineage DAG edge table.
- Grain: one row per (parent artifact, child artifact, relation).
- Columns:
id bigint GENERATED ALWAYS AS IDENTITY PK;organization_id uuid NOT NULL;project_id uuid NOT NULL;parent_artifact_id uuid NOT NULL ON DELETE RESTRICT;child_ artifact_id uuid NOT NULL ON DELETE RESTRICT;relation text NOT NULL(derived_from,stitched_from,encoded_from,supersedes,voice_reference_for,translation_of);created_at. - Unique:
UNIQUE (parent_artifact_id, child_artifact_id, relation);CHECK (parent_artifact_id <> child_artifact_id). - Indexes:
artifact_links_child_idx (child_artifact_id);artifact_links_parent_idx (parent_artifact_id). - State machine: append-only.
- RLS: tenant read; write by the producing service.
- Idempotency: the 3-column UNIQUE.
- Verdict: NEW.
F.21 jobs
- Purpose: the durable work queue that does not exist today.
- Grain: one row per unit of schedulable work.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;project_locale_id uuid;job_type text NOT NULL(source_import,parse,semantic_analysis,identity_review,translation_draft,translation_verify,pronunciation_resolve,voice_cast,voice_sample_generate,audio_render,audio_qa,ebook_export,pdf_export,audio_assembly,release_package,artifact_derivative);payload jsonb NOT NULL;idempotency_key text NOT NULL;priority smallint NOT NULL DEFAULT 100;deadline_at timestamptz;required_pool text;status text NOT NULL DEFAULT 'queued'(queued,leased,running,succeeded,failed,cancelled,dead_letter);attempt_count integer NOT NULL DEFAULT 0;max_attempts integer NOT NULL DEFAULT 3;next_attempt_at timestamptz NOT NULL DEFAULT now();last_error text;progress jsonb NOT NULL DEFAULT '{}';output_job_id uuid;created_at,updated_at;row_version integer NOT NULL DEFAULT 1;created_by_user_id uuid;created_by_service_id uuid. - Unique:
UNIQUE (project_id, job_type, idempotency_key). The idempotency key is content-derived, never a filename — this is the direct answer to spec 19 §12.2. - Indexes:
jobs_claim_idx (status, next_attempt_at, priority) WHERE status IN ('queued', 'leased');jobs_project_idx (project_id, created_at DESC). - State machine:
queued → leased → running → succeeded | failed;failed → queuedwhileattempt_count < max_attempts;failed → dead_letterwhen exhausted; any →cancelled. - RLS: tenant read; human write requires the permission mapped to
job_type; worker write/update permitted only for a row whosejob_leasesentry names the caller's service identity and is unexpired. - Classification: payload may contain text → C.
- Idempotency/concurrency: claim with
UPDATE jobs SET status='leased' WHERE id = (SELECT id FROM jobs WHERE ... FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *. - Verdict: NEW.
F.22 job_attempts
- Purpose: per-attempt history and the structured log pointer.
- Grain: one row per attempt.
- Columns:
id bigint GENERATED ALWAYS AS IDENTITY PK;organization_id,project_idNOT NULL;job_id uuid NOT NULL ON DELETE CASCADE;attempt_number integer NOT NULL;service_identity_id uuid NOT NULL;lease_id uuid;started_at,finished_at;outcome text NOT NULL CHECK (outcome IN ('succeeded','failed','cancelled','expired'));error_class text;error_message text;log_uri text;metrics jsonb NOT NULL DEFAULT '{}'. - Unique:
UNIQUE (job_id, attempt_number). - Indexes:
job_attempts_job_idx (job_id, attempt_number DESC). - State machine: append-only.
- RLS: tenant read; insert only by the lease holder.
- Idempotency: the UNIQUE; a retried attempt increments the number.
- Verdict: NEW.
F.23 job_leases
- Purpose: worker ownership with heartbeat and expiry — the mechanism that lets RLS scope a worker to a project without impersonation.
- Grain: one row per active lease.
- Columns:
id uuid PK;organization_id,project_idNOT NULL;job_id uuid NOT NULL ON DELETE CASCADE;service_identity_id uuid NOT NULL;lease_token_hash char(64) NOT NULL;acquired_at timestamptz NOT NULL DEFAULT now();heartbeat_at timestamptz NOT NULL DEFAULT now();expires_at timestamptz NOT NULL;released_at timestamptz;worker_fingerprint text. - Unique:
UNIQUE (job_id) WHERE released_at IS NULL— one live lease per job;UNIQUE (lease_token_hash). - Indexes:
job_leases_expiry_idx (expires_at) WHERE released_at IS NULL(reaper);job_leases_service_idx (service_identity_id, expires_at). - State machine:
active → released | expired. A reaper moves expired leases back toqueuedand incrementsattempt_count. - RLS: insert/renew by the worker for its own service identity; a worker may read only its own rows.
- Idempotency:
ON CONFLICT (job_id) WHERE released_at IS NULL DO UPDATE SET heartbeat_at, expires_at— heartbeat is idempotent and repeatable. - Verdict: NEW. This table plus
service_identitiesis the fix for BLOCKER B1.
F.24 service_identities
- Purpose: a non-human principal. A worker or service is never a
usersrow. - Grain: one row per service principal.
- Columns:
id uuid PK;organization_id uuid NULL(null = platform-wide);name citext NOT NULL;kind text NOT NULL CHECK (kind IN ('audio_worker','analysis_worker', 'export_worker','migration_tool','readonly_report'));status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled'));description text;created_at,updated_at. - Unique:
UNIQUE (name). - Indexes:
service_identities_org_idx (organization_id, status). - State machine:
active ↔ disabled. - RLS: readable by
platform_adminandorganization_admin; not readable by ordinary users. Writes byplatform.manageonly. - Classification: P (operational identity).
- Idempotency:
ON CONFLICT (name) DO NOTHING. - Verdict: NEW. Required by BLOCKER B1.
F.25 legacy_id_map
- Purpose: the permanent, auditable bridge from every legacy identifier to its canonical enterprise id. This is the mechanism that makes rollback and re-sync safe forever.
- Grain: one row per (legacy system, entity kind, legacy key).
- Columns:
id uuid PK;organization_id uuid NOT NULL;project_id uuid NULL;legacy_system text NOT NULL(umbra_sqlite,umbra_json,umbra_registry,umbra_file);legacy_source_ref text NOT NULL(e.g.project.db:utterances:4711,ch11/utt_0042);entity_kind text NOT NULL(project,chapter,source_unit,speaker,voice_profile,artifact,job);entity_id uuid NULL;legacy_key text NOT NULL;source_sha256 char(64);mapping_state text NOT NULL DEFAULT 'mapped' CHECK (mapping_state IN ('mapped','unmapped','ambiguous','rejected'));confidence numeric(4,3);note text;mapped_at timestamptz NOT NULL DEFAULT now();mapped_by_service_id uuid`. - Unique:
UNIQUE (legacy_system, legacy_source_ref, legacy_key). - Indexes:
legacy_id_map_entity_idx (entity_kind, entity_id);legacy_id_map_unmapped_idx (project_id) WHERE mapping_state <> 'mapped'. - State machine:
unmapped → mapped | ambiguous | rejected. - RLS: tenant read; write by migration tooling only.
- Classification: C.
- Idempotency: the UNIQUE; re-running the mapper is a no-op.
- Verdict: NEW. This is where unmappable historical records go —
mapping_state=unmapped/ambiguous/rejectedis the answer to "historical local records that cannot be mapped" (§I.7). Nothing is dropped.
F.26 Extensions to existing 001_control_plane tables
| Table | Change | Why |
|---|---|---|
projects |
ADD work_id uuid → works(id) ON DELETE RESTRICT |
§E item 1; a project produces one work |
projects |
ADD source_locale text NOT NULL DEFAULT 'en-US' |
project_locales.is_source needs an anchor |
projects |
ADD row_version integer NOT NULL DEFAULT 1 |
optimistic concurrency |
projects |
ADD migration_state text NOT NULL DEFAULT 'legacy' CHECK (migration_state IN ('legacy','mirroring','dual_write','enterprise_authoritative','retired')) |
the per-project cutover flag (§I.5) |
projects |
ADD legacy_project_path text |
the old /Volumes/.../<slug> directory, so both systems can find the same book |
users |
ADD password_disabled_at timestamptz |
defence-in-depth: makes "no second password store" explicit and auditable |
sessions |
ADD mfa_verified_at timestamptz, ADD idle_deadline_at timestamptz |
B4 fix; idle timeout |
audit_events |
ADD actor_service_identity_id uuid → service_identities(id) ON DELETE SET NULL |
B1 fix — worker attribution |
audit_events |
ADD principal_kind text NOT NULL DEFAULT 'user' CHECK (principal_kind IN ('user','service','system')) |
explicit principal type |
audit_events |
ADD actor_subject_snapshot text |
immutability of identity behind an audit row when users.email later changes (§J.5) |
audit_events |
ADD session_id uuid → sessions(id) ON DELETE SET NULL |
ties an authorization decision to a session |
permissions |
ADD rows (§D.5) | B3 fix |
roles |
ADD audio_engineer (project), producer (project) if required |
optional; decide in M |
Deliberately NOT changed: audit_events.project_id and audit_events.organization_id get
no foreign keys. Audit rows must survive the deletion of the project and organization they
describe; an FK would either block deletion or cascade the audit trail away. The absence of
those FKs is a feature and should be documented as intentional so a future reviewer does not
"fix" it.
G. Shared-record and cross-language invariants
Each is stated as a testable invariant with its enforcement point.
- One source ebook has one stable source identity. Invariant: for a given
(organization_id, sha256)there is at most onesource_documentsrow. Enforced:UNIQUE (project_id, sha256)+ content-addressed ingest. - One canonical project contains the audiobook and the translation work.
Invariant: every
source_units,speakers,artifacts,jobsrow for a book resolves to exactly oneprojects.id, and that id is shared by UmbraAudio and UmbraTrans. Enforced: NOT NULLproject_id+ composite FK + composite RLS. - Analysis has immutable versions. Invariant: no
UPDATEon asource_revisionsrow withlocked = true, and noUPDATE/DELETEonanalysis_runs/analysis_snapshotsin statesucceeded. Enforced:BEFORE UPDATEtriggers; append-only trigger onaudit_eventsalready exists as a precedent (001:356-359). - Speaker identity is stable across languages. Invariant:
speakers.canonical_nameand its uuid are the only cross-language speaker reference;translation_units.source_unit_keyandspeaker_idcarry that identity, never a translated display name. Enforced: no translated name is ever written tospeakers. - A voice profile is reusable across translations only when language, accent, consent and
licensing allow. Invariant: a row in
voice_castsmay only be created whenvoice_licences.status='active'and the licence covers the targetlocale_tag, the requested territory, the media, and the release date, and the profile'slocale_tag/accentare compatible. Enforced: deny-by-default trigger onvoice_casts+app.voice_licence_covers(...). - Every translation references a source book, a project and a specific analysis version.
Invariant:
translation_runs.source_revision_id IS NOT NULLand itsinput_sha256matches theanalysis_runsinput for the same revision. Enforced: NOT NULL FK + a reconciliation check in the nightly validator. - Every audiobook references a project, a translation when applicable, a voice-cast
snapshot and a generation run. Invariant: every
artifactsrow withartifact_kind IN ('m4b','mp3_track','book_master','chapter_master')hasaudio_run_id IS NOT NULLandcast_snapshot_sha256resolvable inaudio_run_casts. Enforced: NOT NULL FK +CHECK+ the nightly lineage validator. - Translated ebooks and audiobooks never become independent duplicate source records.
Invariant: a translated artifact's lineage path to a
source_documentsrow always exists and is acyclic; no translated artifact is ever registered as asource_documentsrow. Enforced:artifact_linksacyclicity check (recursive CTE, nightly) + application rule. - The audio system keeps creating audiobooks before, during and after cutover. Invariant: the audio worker's critical path never requires a successful enterprise write. Enforced: degraded-mode lease (§I.6); PG write failure is logged and reconciled, never fatal. This is the invariant that makes the whole plan safe, and it must be tested by stopping PostgreSQL mid-run (§L, test 14).
- Large media live in object storage; PostgreSQL holds only object keys, checksums, media
metadata and lifecycle state. Invariant: no table in this design has a
bytea/blobcolumn. Enforced: schema review + a test asserting nobyteacolumn exists in schemapublic. - Every write is idempotent and safe to retry. Invariant: every table has a natural unique constraint; re-executing any write with identical inputs changes nothing. Enforced: the UNIQUE constraints in §F; replay tests in §L.
- Concurrent workers cannot silently overwrite one another. Invariant: a job has at most
one live lease; metadata edits use
row_version. Enforced: partial UNIQUE onjob_leases,FOR UPDATE SKIP LOCKEDclaims,row_versionon every mutable table. - Service identities are not represented by arbitrary client-supplied user ids.
Invariant:
audit_events.principal_kind='service'rows haveactor_service_identity_id IS NOT NULLandactor_user_id IS NULL. Enforced:CHECKconstraint + the GUC-derivation rule in §J.1 (the service identity comes from the connection, never from a payload). - The future translation UI can discover authorized projects and audiobooks without a
separate account system. Invariant: the UI's project list is exactly
SELECT id FROM projects WHERE app.can_access_project(id)— the same predicate the audio system uses. Enforced: both clients use oneapp.authorized_projects()function. - The existing staleness rule survives. Invariant: an artifact whose
legacy_marker.fx_versionis set is stale for a run that requests nofx_version(generation/staleness.py:98). Any enterprise re-implementation of staleness must preserve this, because the two current generation drivers differ on it (ChapterBuilderdoes not passinstruct/fx_version;scripts/generate_all_audio.pydoes). Enforced: documented and covered by a regression test.
H. Schema delta and migration order
No SQL is emitted. Each row is a reviewed change with its own rollback and verification.
| # | Class | Change | Reason | Depends on | Rollout | Rollback | Volume | Lock risk | Verification | Human approval |
|---|---|---|---|---|---|---|---|---|---|---|
| H1 | NEW SERVICE ROLE | service_identities principal + app.current_service_identity_id() |
B1 blocker | — | additive | drop function | 0 | none | §L-6 | Yes |
| H2 | ADD TABLE | service_identities |
B1 | H1 | additive | drop table | ~5 rows | none | §L-6 | Yes |
| H3 | ADD TABLE | works, source_documents, source_revisions, source_chapters |
§E 1 | — | additive | drop | 10s–100s | none | §L-8 | Yes |
| H4 | ADD TABLE | legacy_id_map |
§I stable-ID mapping | H3 | additive | drop | 10⁵ rows | none | §L-8 | Yes |
| H5 | ALTER TABLE | projects: work_id, source_locale, row_version, migration_state, legacy_project_path |
cutover flag + work link | H3 | nullable columns, then backfill, then NOT NULL on source_locale |
drop columns | ~10 rows | brief ACCESS EXCLUSIVE; use NOT VALID patterns, lock_timeout |
§L-5 | Yes |
| H6 | ADD TABLE | speakers, speaker_aliases |
5-store identity fix | H3 | additive | drop | ~10² | none | §L-9 | Yes |
| H7 | ADD TABLE | source_units (+ generated source_unit_key) |
§E.1 stable identity | H3, H6 | additive | drop | 10⁴–10⁶ | none | §L-9 | Yes |
| H8 | ADD TABLE | analysis_runs, analysis_snapshots |
immutable analysis | H3 | additive | drop | 10² | none | §L-10 | Yes |
| H9 | ADD TABLE | voice_profiles, voice_profile_revisions, voice_licenses, voice_casts |
voice reuse gating | H6 | additive | drop | 10² | none | §L-11 | Yes |
| H10 | ADD TABLE | project_locales, translation_runs, translation_units |
locale lanes | H3 | additive | drop | 0 initially | none | §L-12 | Yes |
| H11 | ADD TABLE | audio_runs, audio_run_casts |
generation runs | H7, H9 | additive | drop | 10²–10³ | none | §L-13 | Yes |
| H12 | ADD TABLE | artifacts, artifact_links |
artifact registry + lineage | H11 | additive | drop | 10⁴–10⁶ | none | §L-13 | Yes |
| H13 | ADD TABLE | jobs, job_attempts, job_leases |
durable queue | H1 | additive | drop | 10³–10⁵ | none | §L-14 | Yes |
| H14 | ADD CONSTRAINT | artifacts: CHECK requiring audio_run_id for audio kinds; artifact_links self-edge CHECK |
G7 | H12 | NOT VALID then VALIDATE |
drop constraint | large | none if NOT VALID |
§L-13 | Yes |
| H15 | ALTER TABLE | audit_events: actor_service_identity_id, principal_kind, actor_subject_snapshot, session_id |
worker attribution | H1, H2 | nullable add | drop columns | large table; add nullable only | brief | §L-7 | Yes |
| H16 | ENABLE/PROVE RLS | RLS policies on all 20 new tables, tenant read/write, worker-lease branch | §J | H2–H13 | create policies, then ENABLE, then prove |
drop policies | — | none | §L-1,2,3 | Yes |
| H17 | ALTER TABLE | sessions: mfa_verified_at, idle_deadline_at |
B4 | — | nullable add | drop columns | small | brief | §L-4 | Yes |
| H18 | ADD CONSTRAINT | users.password_disabled_at |
no second password store, explicit | — | nullable add | drop column | small | brief | §L-4 | Yes |
| H19 | BACKFILL | Insert permissions rows + role_permissions grants from §D.5 |
B3 | — | idempotent upserts | delete the added rows | ~15 rows | none | §L-5 | Yes |
| H20 | ADD CONSTRAINT | users.status='active' + organizations.status='active' enforcement inside app.resolve_session |
B5 | H17 | replace function | revert function | — | none | §L-4 | Yes |
| H21 | ADD FUNCTION | app.revoke_user_sessions(uuid), app.authorized_projects(), app.can(action, project_id) |
D.3, D.5, G14 | H1 | create | drop | — | none | §L-2,4 | Yes |
| H22 | ENABLE/PROVE RLS | Replace projects_select, organizations_select, user_roles_select policies to (a) admit project-only members to their org, (b) admit voice_manager to project metadata, (c) scope the worker |
§J.3, §J.4 | H1, H21 | CREATE OR REPLACE in one transaction |
restore prior policy text | small | brief | §L-2,3 | Yes |
| H23 | GRANT/REVOKE | ALTER DEFAULT PRIVILEGES — revoke EXECUTE ON FUNCTIONS from PUBLIC in schema app; grant table DML to umbracontrol_api; grant lease-scoped DML to umbracontrol_worker |
B7 — new app functions are world-executable by default today |
H1 | per-migrator ALTER DEFAULT PRIVILEGES |
revert | — | none | §L-15 | Yes |
| H24 | GRANT/REVOKE | Re-own app schema + all tables from postgres to umbracontrol_owner (NOLOGIN); migrations connect as umbracontrol_migrator then SET ROLE umbracontrol_owner; never FORCE ROW LEVEL SECURITY |
§J.2 superuser dependency | H16 | maintenance window; REASSIGN OWNED in one transaction |
REASSIGN OWNED ... TO postgres |
all | brief; blocks writes | §L-16,17 | Yes — highest risk item |
| H25 | BACKFILL | Project → projects (with legacy_project_path); manuscript → source_documents; speakers → speakers/speaker_aliases; utterances → source_units; voice refs → voice_profiles (+voice_licenses pending); artifacts → artifacts (+legacy_object_key, legacy_marker) |
§I.4 | H4–H12 | resumable, batched, idempotent | delete by legacy_id_map batch |
10⁵–10⁶ | none (batched) | §L-8,9,11,13 | Yes |
| H26 | ADD INDEX | All indexes in §F, created CONCURRENTLY |
query paths | tables exist | CREATE INDEX CONCURRENTLY, one at a time |
DROP INDEX |
large | none | EXPLAIN checks |
No |
| H27 | ADD CONSTRAINT | Triggers: immutable revisions, status state machines, four-eyes approval, cast licence gate, row_version bump |
G3, G5, G12 | tables exist | create, then backfill-violation audit | drop triggers | — | none | §L-13,18 | Yes |
| H28 | NEW SERVICE ROLE | Optional audio_engineer / producer roles |
§M open question | H19 | seed upsert | delete | 0 | none | §L-5 | Yes |
| H29 | NO CHANGE | organizations, roles, role_permissions, invitations, mfa_methods, bootstrap_tokens, schema_migrations |
no delta required | — | — | — | — | — | — | No |
| H30 | NO CHANGE | Every legacy SQLite table, JSON file, WAV, MP3, M4B, marker | §G invariant 9 | — | — | — | — | — | — | No |
Dependency chain: H1 → H2 → H3 → H4 → {H5, H6, H7, H8} → {H9, H10, H11} → H12 → H13; H16 after all tables; H22–H24 last (they change the enforcement layer and must be proven against a fully-populated schema).
I. Gradual cutover, reconciliation, and rollback plan
I.1 The repository/adapter boundary (Phase P1 — no behaviour change)
Introduce a single seam in the audio codebase, behind which all state access lives:
umbraaudio/persistence/
ProjectRepository (protocol)
SqliteProjectRepository -> today's project.db + JSON + files [DEFAULT]
ControlPlaneProjectRepository -> enterprise PostgreSQL
ArtifactStore
LocalArtifactStore -> today's filesystem tree [DEFAULT]
ObjectArtifactStore -> enterprise object storage
Rule: the default implementation is today's code, byte-for-byte. Phase P1 ships with the enterprise implementation present but unused behind a per-project flag. Nothing about audiobook production changes. This is what makes the rest of the plan reversible.
I.2 Read-only mirror / shadow-read phase (P2)
- H25 backfill populates the enterprise DB from SQLite/files, recording every mapping in
legacy_id_map. - The audio system keeps reading and writing only SQLite/files. Zero risk.
- A nightly shadow-read job runs the same queries through both repositories and records discrepancies in a reconciliation table. Discrepancies are logged, never fatal.
I.3 Stable-ID mapping and reconciliation rules
| Legacy identity | Canonical id | Rule |
|---|---|---|
<base>/<slug>/ directory |
projects.id |
legacy_project_path; slug collisions resolved by suffix, never overwritten |
SQLite project.id |
projects.id |
via legacy_id_map |
chapters.id |
source_chapters.id |
within a source_revision |
utterances.id / utterance_index |
source_units.source_unit_key |
§E.1; utterance_index alone is never used as an id |
characters.id, speakers.json primary_name, voice_profiles.json key, voice_registry.character_name, library_speakers.primary_name |
speakers.id |
five sources merged; conflicts recorded, ambiguous ones left mapping_state='ambiguous' |
voices/{slug}_ref.wav |
voice_profiles.id |
reference_object_key; voice_licenses seeded pending |
chapter_audio/ch{N}/utt_{I}.wav |
artifacts.id |
legacy_object_key; legacy_marker = parsed *.wav.txt |
ch{N}_full.wav / .mp3 / export/*.m4b / ar_package/*.mp3 |
artifacts.id |
legacy_object_key |
generation_status.json, ch{N}_build_status.json |
audio_runs + jobs |
seeded as succeeded/partial with legacy_marker |
'user' in reviewed_by / resolved_by |
the single canonical users.id |
legacy_system='umbra_json', note legacy_actor='user' |
Reconciliation rules (nightly): row counts per entity per project; sha256 of every
registered artifact re-verified against the file; status-transition legality (no
succeeded → running); lineage completeness (§G7); mapping_state <> 'mapped' counts; and
speaker-name consistency between speakers.canonical_name and speaker_aliases.
I.4 Backfill plan
Order: projects → works/source documents/revisions → speakers + aliases → source units →
analysis runs/snapshots → voice profiles + licences + casts → artifacts + links → audio runs →
jobs. Batched at 1000 rows, resumable, idempotent, each batch one transaction, and each batch
only advances migration_state after its reconciliation passes. peak memory and runtime are
bounded by batching; nothing is held in memory.
I.5 Feature flag / per-project cutover
projects.migration_state ∈ legacy | mirroring | dual_write | enterprise_authoritative | retired. The audio worker reads it per request; a per-project override in local config allows
an immediate per-project rollback without a database change. Cutover is per project, never
global, and is reversible in one flag flip.
I.6 Dual-write / write-through period
For a project in dual_write:
- Primary path: unchanged. Audio generation writes the WAV, then the marker, then the SQLite row — exactly as today. It never waits on PostgreSQL.
- Secondary path: best-effort mirror of the small, high-value records:
artifacts(object key +sha256+legacy_marker),audio_runsprogress,jobs,speakers,source_units,audit_events. - On PostgreSQL failure: log, increment a
mirror_deficitcounter, and continue. The audio work is never blocked, never rolled back, never retried inline. A reconciler replays the deficit from the local state. This is the concrete meaning of G9. - Degraded mode: if PostgreSQL is unreachable when a worker starts a run, the worker continues under a previously issued lease with a bounded validity (6 h), after which it must re-check in. Exceeding that window halts new runs, not in-flight rendering, and raises an operator alert. Production is degraded, never stopped, and never silently un-reconciled.
- TTS saturation note: the Mac serves one TTS model and must not co-reside with the LLM
(AGENTS.md). The
resource_poolcolumn onjobs(tts_local,llm_local,gpu_remote,cpu) is what lets the queue express that constraint, replacing today's in-process lock.
I.7 Historical local records that cannot be mapped
They are never dropped. They are written to legacy_id_map with
mapping_state ∈ ('unmapped','ambiguous','rejected'), a note, and the original
legacy_source_ref. A monthly report lists them. Resolution is a human decision
(merge / confirm / retire). The five-way speaker conflict (§I.3) is the largest expected
population. This directly satisfies "a plan for historical local records that cannot be mapped".
I.8 The point at which the enterprise database becomes authoritative
Per project, when all of the following hold for three consecutive nightly reconciliations:
counts match; every registered artifact's sha256 matches; zero mapping_state='unmapped'
rows for that project; a full generation run has completed with PostgreSQL as the primary
store (the enterprise_authoritative dry-run sub-phase); and a restore drill (§K) has passed.
Then migration_state = 'enterprise_authoritative': UmbraAudio reads project state,
speakers, units and artifacts from PostgreSQL; SQLite becomes a read-only mirror; the
filesystem remains the audio working area and the *.wav.txt markers remain the idempotency
record, so the TTS path is unchanged. Historical local records are retained read-only
indefinitely.
I.9 Rollback plan
- Flip
migration_stateback todual_write(ormirroring). One statement. - The audio worker reverts to SQLite + files as primary. The worker is never stopped.
- Enterprise rows written during the dual-write window are retained, not deleted; they are
marked
superseded_by_localso reconciliation knows they are not authoritative. - No source data is deleted at any point in this plan. Legacy SQLite, JSON, WAV, MP3, M4B and markers are all read-only after cutover and are never pruned until a separate, explicit retention decision (§M).
- Rollback is safe at any point, because Phase P1's default repository is still the current code.
J. Security / RLS review
J.1 Can RLS be bypassed by client-controlled settings? — Yes. Documented residual risk.
app.current_user_id() is:
-- 001_control_plane.sql:218-224
SELECT NULLIF(current_setting('app.user_id', true), '')::uuid
app.user_id is a custom placeholder GUC, and any role may SET it. There is no
REVOKE SET ON PARAMETER for a GUC that does not exist in pg_settings, so this cannot be
prevented in PostgreSQL. Consequences:
- Any holder of the
umbracontrol_apiorumbracontrol_workercredential can impersonate any user, including aplatform_admin, and read everything that user's RLS permits. - A non-UUID value raises
22P02, a minor robustness/DoS issue.
This is the standard pattern used by Supabase/PostgREST and it is acceptable only under compensating controls, all of which are mandatory:
- The API/worker credential lives in a root-owned
0640file readable only by theumbracontrolservice group (the bootstrap script already creates/etc/umbracontrol/db-credentials.envwith exactly these permissions — preserve that). app.user_id/app.service_identity_idare set withSET LOCALinside an explicit transaction, by trusted framework code only, derived from a verified session token.- No code path may ever read these GUCs from a request body, query parameter, cookie, header, or file. This is a code-review invariant with a lint rule.
- The database is never exposed directly; only the API connects.
ALTER ROLE umbracontrol_api SET log_statement = 'none'is already applied by the bootstrap script'sPGOPTIONS; statement logging of GUC changes is available for incident response.- A dedicated non-superuser owner (§H24) means a superuser is not in the data path.
Residual risk, stated plainly: leakage of the umbracontrol_api password is equivalent to
full tenant bypass. That is why §L-16 tests credential handling and why the API must never
be internet-exposed.
J.2 Superuser ownership of the RLS machinery — HIGH finding
Verified: infra/bootstrap_umbracontrol_database.sh:243 streams the schema through
psql_as_postgres, i.e. as the postgres superuser. Therefore all 13 tables and all
app.* functions are owned by a superuser, and the database itself is owned by
umbracontrol_owner (NOLOGIN, createdb --owner at line 226) which owns nothing else.
Two consequences:
- Superusers bypass RLS unconditionally. This is load-bearing, not incidental: the policy
projects_select USING (app.can_access_project(id))calls a function that itself doesSELECT 1 FROM projects …(001:298-320). That is a self-referencing policy. It does not raise42P17 infinite recursiononly because theSECURITY DEFINERfunction runs as a superuser and therefore bypasses RLS on the inner query. - Therefore
ALTER TABLE projects FORCE ROW LEVEL SECURITYwould break the entire access model with42P17. Equally, re-owning theappfunctions to a role that does not own the tables would break it. The only working configuration is: theapp.*SECURITY DEFINER functions and the tables they read must share one owner, and RLS must not be FORCE'd on the mutually-referencing tables (organizations,projects,users,user_roles).
Required (H24): re-own the app schema and all tables to the dedicated NOLOGIN role
umbracontrol_owner; run migrations as umbracontrol_migrator then SET ROLE umbracontrol_owner. This preserves the required semantics, removes the superuser from the data
path, and makes the invariant explicit. Add the regression assertion in §L-17 so nobody later
"improves" it with FORCE.
J.3 voice_manager cannot see any project — authorization gap
voice_manager is seeded at scope='organization' (001:373). projects_select requires
can_access_project, which admits only platform admins, organization admins, and holders
of a scope='project' role. An org-scoped voice_manager therefore matches zero projects
and cannot perform their job. Fix (H22): a policy branch granting org-scoped non-admin roles
visibility of project metadata only (id, name, slug, status) — implemented as a
column-restricting view or a column_privilege arrangement, not a blanket row grant. This
requires a human decision (§M Q3): least privilege vs. usability.
J.4 Project-only members cannot read their own organization — availability gap
organizations_select requires is_organization_member, which admits only org-scope roles
named organization_admin or member (001:257-261). A user holding only translator on one
project cannot SELECT their organization row, so the UI cannot render an org name. Under-
permissive, not a hole. Fix (H22): also admit any active user_roles row for that organization
at any scope.
J.5 Human session vs worker identity
- Human:
sessionsrow;app.resolve_session(token_hash)→users.id; all authorisation derives fromuser_roles.sessions_self(001:526-529) confines a user to their own rows. - Worker:
service_identitiesrow +job_leasesrow;app.current_service_identity_id(); authorisation derives from an unexpired lease, never from a user. A worker with no lease sees no project rows.audit_events.principal_kinddistinguishes the two permanently. - No worker path may set
app.user_id. Enforced by code review + the fact thatprincipal_kind='service'audit rows requireactor_service_identity_id IS NOT NULL AND actor_user_id IS NULL. - Audit integrity note:
users.emailis mutable andaudit_events.actor_user_idhas a FK with noON DELETEaction, so (a) a user with audit history cannot be deleted (FK violation) and (b) changing a user's email silently changes the identity behind historical audit rows. Fix: H15actor_subject_snapshotfreezes the subject at write time; and document that account deletion is intentionally blocked once audit history exists.
J.6 Pre-existing application-level defects (preconditions, not migration scope)
These must be fixed before the audio web UI is placed behind the enterprise identity layer, because the identity layer is only as strong as the endpoint it guards. Reported, not fixed here:
- No authentication at all. Zero
Depends, zero middleware, zero CSRF, zero security headers, zeroTrustedHost(umbraaudio/web/). 129 routes, 65 mutating, 0 authorization checks. The only gate is the unsignedactive_projectcookie (routes.py:482-491). - The server binds
0.0.0.0:8420over plain HTTP with the macOS firewall disabled and a LAN Caddy proxy in front. Today this is reachable by anyone on the LAN. Until auth lands, the audio UI must remain LAN-only behind an authenticating reverse proxy. load_configaccepts absolute paths verbatim (config.py:156-158), so the cookie is a path-selection primitive.active_project=/any/dirwith/any/dir/.umbra/project.dbgrants full read/write. Verified.- The
activateexistence check is dead code (routes.py:525), becauseload_configpre-creates the directory (config.py:165). - Download traversal:
routes.py:5049usesstr(file_path).startswith(str(export_dir))— a string prefix, not path-component containment. A sibling directory such asexport_secret/satisfies the prefix and is served.Path.is_relative_to()is the correct primitive. The existence check at:5047also runs before the containment check, giving a 404-vs-403 existence oracle. - Upload path traversal:
routes.py:633, 640, 647, 652useupload.filenameraw, with noPath(...).name. A multipart part named../../../evil.txtpasses the suffix allow-list (.txt) and writes outsidedata_dir/uploads. Same pattern atroutes.py:558, 572. (routes.py:6627does it correctly — the fix pattern already exists in the codebase.) - Unauthenticated resource control:
POST /api/models/lm/unload(routes.py:5814) unloads every LM Studio instance;POST /api/models/switch/{stage}unloads/loads GB-scale models. - Unauthenticated destructive operations:
POST /api/pipeline/reset?step=discover(routes.py:4690)shutil.rmtrees the voices directory;POST /api/pipeline/run-all(routes.py:4711) wipesspeakers.json, all utterances and all generated audio. - Zero download authorization. Of 12 file-serving endpoints, none checks a user; the two
slug comparisons (
routes.py:2604, 2991) are cache-buster guards, not authorization. - A second, CWD-relative project-resolution path exists in the
speaker_designationstemplate global (web/__init__.py:51):Path(slug) / ".umbra", with noload_config, no containment check and noproject.dbexistence test. - Concurrent
ChapterBuilderruns are unprotected (routes.py:5097-5110), so two clicks write the sameutt_*.wavconcurrently. - Unauthenticated outbound-request proxy:
POST /api/pronunciations/{name}/lookup(routes.py:1847) fetches attacker-supplied strings fromdictionaryapi.dev. - Destructive manuscript upload:
POST /api/upload-manuscriptwithmode=newrmtree's voice and audio directories (routes.py:712-742) with no confirmation and no permission.
J.7 Service-to-service write authorisation
Writes are authorised by lease, not by role. A worker may UPDATE a jobs row only while
it holds the unexpired job_leases row for that job, and the policy derives that from
app.current_service_identity_id() — never from a parameter. Renewals are
ON CONFLICT (job_id) WHERE released_at IS NULL DO UPDATE, so a heartbeat is idempotent. Audit
inserts by a service require actor_service_identity_id to match the current service identity.
J.8 Can login state be forged or reused across organizations?
- Forged: no. A session requires a 256-bit token whose SHA-256 matches
sessions.token_hash; there is no forgeable signed cookie. - Reused across organizations: yes, by design, and this is correct for a human — a
session is user-global, so one token is valid for every organization that user belongs to. The
protection is that every request re-derives and re-authorises the organization/project
context from the session's user id, and RLS admits only organizations for which that user has
an active
user_rolesrow. There is no "default organization" and no cross-org inheritance. The practical consequence to enforce: never cache an authorised project/organization in the session payload.
J.9 Secrets
001_control_plane.sql contains no secret material and neither does this document.
bootstrap_tokens.token_hash, sessions.token_hash, mfa_methods.secret_reference and
invitations.token_hash are all hash-or-reference columns, never plaintext.
lease_token_hash char(64) (F.23) follows the same pattern. voice_licenses.consent_evidence_ uri points at object storage. The bootstrap admin's one-time token is root-only at
/root/umbra-enterprise-bootstrap.token and, per the infra README, must never be pasted into
chat or logs. Add to the contract: the API must never log a connection string, a token, a GUC
value carrying a user id, or a query parameter that could contain one.
K. Backup and restore requirements
- PostgreSQL PITR with WAL archiving; target RPO ≤ 5 min, RTO ≤ 60 min. Daily full backup retained 35 days, monthly retained 13 months.
- Restore drill before cutover (G-K1): restore a full backup to a scratch instance, then
verify: row counts per table match the source; every
artifacts.sha256matches its object in object storage; every project is reachable by an authorised user and only by authorised users;audit_eventsis intact and append-only; asource_revision, aspeakersrow, avoice_castsrow and anartifactslineage path survive and resolve end-to-end. - Object storage versioning enabled, with server-side checksum validation and
immutability (or object-lock) on released artifacts. A
DELETEmust never be a filesystem unlink: deletion is aretention_class/tombstone transition. - Class R (voice reference audio, embeddings, voice prompts) backed up separately from project data, with its own access control and its own retention/consent policy, because it is biometric data.
- Class C (manuscripts, translations, produced audio) retention tied to the underlying
licence. When an author's licence expires, the derived audiobook and translation
entitlements are withdrawn (
artifacts.availability='withdrawn') — not silently deleted, so the audit trail and any accounting survive. - Legacy sources protected: the local project tree,
~/.umbra/voice_registry.dband all markers are included in the backup set for the whole transition and are read-only after cutover. A full UmbraAudio project export/re-import must preserve lineage (spec 19 §15.3). - The backup role is
umbracontrol_backup(LOGIN,SELECTon all tables, no schemaCREATE) — note it is grantedSELECT ON ALL TABLESat001:576, which is a point-in-time grant; new tables get no backup access until H23 sets default privileges. Backup coverage of new tables is a release gate.
L. Acceptance tests
| # | Test | Pass condition |
|---|---|---|
| L-1 | Two-organization isolation | A user in org A cannot SELECT any row of org B's projects, source_units, speakers, artifacts, jobs via any API route or direct query. |
| L-2 | Authorized project discovery | app.authorized_projects() returns exactly the projects with an active membership; the UI project list equals that set; forging a project id in a path yields 404/403, never data. |
| L-3 | Policy-only membership | A translator on project X can read project X and cannot read project Y in the same org; an org voice_manager sees project metadata but no manuscript text (per the J.3 decision). |
| L-4 | Session lifecycle | Expiry, logout, "sign out everywhere", suspension and deactivated all stop resolution on the next request; a user with mfa_required=true cannot obtain a session without MFA asserted; revoked tokens never resolve. |
| L-5 | Permission enforcement | Each action in §D.5 is denied for a role lacking the permission and allowed for a role holding it — including project.audio.generate and project.speakers.edit, which have no equivalent today. |
| L-6 | Worker lease scoping | A worker holding a lease on project X can read/write X; the same worker on project Y (no lease) sees zero rows and every write raises 42501. A worker cannot read sessions, mfa_methods, or users.password-like columns. |
| L-7 | Worker audit attribution | Every worker-initiated mutation produces an audit_events row with principal_kind='service', actor_service_identity_id set, actor_user_id NULL, and the correct project_id. |
| L-8 | Backfill completeness | For every project: legacy project count = projects count; every legacy chapter/utterance has a legacy_id_map row; re-running the backfill changes zero rows. |
| L-9 | Identity merge correctness | All five legacy speaker stores map to one speakers.id per canonical name; source_units.speaker_name values are all present in speakers.canonical_name ∪ speaker_aliases.alias; no utterance references a missing speaker. |
| L-10 | Analysis immutability | An UPDATE on a locked source_revisions row, or on a succeeded analysis_runs row, raises. |
| L-11 | Voice licence gating | A cast against a pending/expired/takedown licence, or one not covering the target locale/territory/media/date, is rejected; an existing render is not deleted and a project issue is raised instead. |
| L-12 | Locale independence | Two locales on one project have fully independent translation/cast/audio/release state; no table is named for a language. |
| L-13 | Lineage + idempotency | Every audio artifact resolves a complete acyclic path to a source_documents row through artifact_links; re-running an identical audio_runs payload creates no new row; two concurrent claimers produce exactly one lease. |
| L-14 | Continuity under database outage | With PostgreSQL stopped mid-generation, audio production continues to completion; on restart, the mirror reconciler converges with zero lost artifacts; the 6 h lease window then halts new runs while in-flight rendering completes. |
| L-15 | Default-privilege safety | A newly created function in schema app is not executable by PUBLIC; a newly created table is readable/writable by umbracontrol_api and scoped for the worker. |
| L-16 | Credential handling | The database credential file is root-owned 0640; no secret appears in any log line, environment dump, report, or migration file; the API is not reachable without a session. |
| L-17 | RLS invariant | relforcerowsecurity is false for organizations, projects, users, user_roles; app.* functions and the tables they read share one owner; no superuser owns any object in schema public or app. |
| L-18 | Concurrency | Two reviewers editing the same translation_units row: the second receives a conflict (row_version), never a silent overwrite. |
| L-19 | Unmapped history | Records that cannot be mapped appear in legacy_id_map with mapping_state set and are never dropped; the monthly report is non-empty and actionable. |
| L-20 | Application regression | The three verified defects are fixed and tested: (a) GET /api/export/download/../export_secret/x.mp3 returns 403; (b) an upload named ../../../evil.txt cannot escape data_dir/uploads; (c) active_project=/tmp/evil is rejected. |
M. Open questions and blockers
M.1 BLOCKERS — must be resolved before any implementation
| ID | Blocker | Evidence | Resolution |
|---|---|---|---|
| B1 | The worker role cannot read any project and cannot attribute an audit event. All RLS policies require app.current_user_id(); the worker sets none, so it matches zero rows. audit_events_insert requires actor_user_id = app.current_user_id(), so worker actions are anonymous. |
001:471-473, 548-552 |
H1/H2: service_identities + app.current_service_identity_id() + lease-scoped policy branch + audit_events.actor_service_identity_id. Do not work around it by letting the worker set app.user_id. |
| B2 | No permission exists for generating audio, editing speakers, casting voices, running analysis, or destroying project state. The prompt requires authorization checks for exactly these. | 001:378-391 |
H19. Without it, the contract is unimplementable as written. |
| B3 | mfa_required is stored but never enforced, and sessions has no mfa_verified_at. A user flagged mfa_required=true can hold a fully valid session. |
001:30, 120-129 |
H17 + app.resolve_session. |
| B4 | New functions in schema app are EXECUTE-able by PUBLIC by default. REVOKE ALL ON ALL FUNCTIONS at 001:580 was a one-time statement with no ALTER DEFAULT PRIVILEGES behind it. Every future migration that adds a SECURITY DEFINER helper silently publishes it. |
001:580 |
H23, applied before any further migration. |
| B5 | users.status and organizations.status are not enforced by any policy. Suspension does not deny access at the database layer. |
001:28-29, 15-17 |
H20: enforce inside app.resolve_session, plus a documented decision that RLS is not the suspension gate. |
M.2 HIGH findings (not blockers, must be scheduled)
- H-1: superuser ownership of all RLS machinery (§J.2) — H24.
- H-2:
voice_managercannot see any project (§J.3) — H22. - H-3: project-only members cannot read their organization (§J.4) — H22.
- H-4:
app.user_idimpersonation by any credential holder (§J.1) — accepted residual risk with six mandatory compensating controls. - H-5: no admin session revocation (§D.3) — H21.
- H-6:
users.emailmutability undermines historical audit identity (§J.5) — H15. - H-7:
umbracontrol_backupgets noSELECTon tables created after001until H23. - H-8: the four divergent legacy DDL authorities (§C.3) mean the legacy schema cannot be trusted as a backfill source without per-database introspection.
M.3 DECISIONS requiring human adjudication
- DECISION-1 (highest). This document reverses
specs/19-enterprise-control-plane.md§2.3 and §16 M4, which specify a one-way read-only import with UmbraAudio unchanged. The new instruction makes the enterprise DB authoritative for the audio system. Confirm the reversal; spec 19 should then be amended so the two documents do not contradict each other. Recommendation: adopt the new model, and amend spec 19 §2.3/§16 M4 to "the enterprise database is authoritative; UmbraAudio is a client, with the filesystem retained as the audio working area." Spec 19 §1's prohibition on control-plane tables inside local SQLite stands. - DECISION-2.
specs/18-UmbraTrans.md§4.2 forbids UmbraTrans from modifying UmbraAudio and specifies an online-backup snapshot protocol (§4.2.1). Under the new model, UmbraTrans reads the same authoritative database instead. Confirm that §4.2.1 is superseded — this simplifies spec 18 and removes the snapshot-consistency problem entirely. - DECISION-3. Org-scoped
voice_managerproject visibility: metadata-only (recommended) or full membership required? - DECISION-4. Should
audio_engineerandproducerroles be added, or isproject_manager+audio_directorsufficient? - DECISION-5. Retention: how long are Class C artifacts and legacy SQLite kept after cutover? The plan assumes indefinite read-only retention.
- DECISION-6. Which volume actually hosts object storage, and is it on the same host as
PostgreSQL? Object storage is a hard prerequisite for §G invariant 8; until it exists,
artifacts stay on the local filesystem and the enterprise DB holds only
object_keyvalues that resolve locally. - DECISION-7. Is the identity provider external OIDC, or is the
bootstrapprovider promoted to first-class?001defaultsauth_provider='bootstrap', which is not a production identity mechanism. - DECISION-8. Does the audio UI get retired in favour of the shared review UI, or do both ship? This determines whether the J.6 fixes are a prerequisite or parallel work.
- DECISION-9. Scope of phase 2 (deferred in §E): the UmbraTrans translation-design tables
(
semantic_packages,identity_registries,glossary_revisions,memory_revisions,alignment_links,target_render_spans) plus the commercial voice subsystem (voice_entitlements,voice_reservations,voice_rate_cards,voice_invoices).
M.4 Explicitly out of scope for this phase
Connecting to any database; any DDL, DML, migration, grant or role change; moving or rewriting any UmbraAudio file; stopping or pausing the audio worker; producing migration SQL; requesting or using any credential, token, key or connection string.
N. Machine-readable contract
contract:
name: umbra-enterprise-database-integration
version: 1.0.0-draft
date: 2026-09-25
phase: discovery_and_design
status: awaiting_human_approval
production_changes_made: false
database_contacted: false
migration_sql_emitted: false
baseline:
schema_reference: infra/postgres/001_control_plane.sql
schema_version: 001_control_plane
sha256_verified_identical_copy_at: /Volumes/Dock/worstauthor/oldbox/postgres/001_control_plane.sql
existing_tables:
- schema_migrations
- organizations
- users
- projects
- roles
- permissions
- role_permissions
- user_roles
- invitations
- sessions
- mfa_methods
- bootstrap_tokens
- audit_events
verified_absent:
- no source/work/edition tables
- no locale tables
- no analysis tables
- no speaker or voice tables
- no license or entitlement tables
- no translation tables
- no audio run or artifact tables
- no job, lease, attempt or idempotency tables
- no service identity table
- no project_members table
- no password column on users
existing_roles: [platform_admin, organization_admin, member, project_owner,
project_manager, translator, proofreader, audio_director,
voice_manager, release_manager, viewer]
existing_permissions: [platform.manage, organization.manage, organization.users.manage,
organization.projects.manage, project.access.manage,
project.source.edit, project.translation.edit,
project.translation.review, project.audio.approve,
project.release.approve, voice.catalog.manage, artifact.download]
ownership:
tables_and_app_functions_owner: postgres # SUPERUSER - see finding H-1
database_owner: umbracontrol_owner # NOLOGIN, owns nothing else
migrations_applied_as: postgres # bootstrap_umbracontrol_database.sh:243
rls:
enabled_on: [organizations, projects, users, roles, permissions, role_permissions,
user_roles, invitations, sessions, mfa_methods, bootstrap_tokens,
audit_events]
forced_on: []
invariant: >-
app.* SECURITY DEFINER functions and the tables they read MUST share one owner,
and RLS MUST NOT be FORCE'd on organizations, projects, users, user_roles.
Violating either causes 42P17 infinite recursion in policy for relation "projects".
current_state:
authentication: none
authorization_checks: 0
routes_total: 129
mutating_endpoints: 65
project_identifier: unsigned browser cookie "active_project", max_age 1 year
job_queue: none
job_identity: output filename derived from (chapter_id, utterance_index)
leases_or_heartbeats: none
retry: linear sleep, innermost loop only; batch driver has none
durable_utterance_identity: none
artifact_checksums: none
checksummed_content: voice reference fingerprint only (sha256 truncated to 16 hex)
local_accounts_to_migrate: 0
legacy_identity_strings: ["'user' in voice_review_history.reviewed_by",
"'user' in exception_reviews.resolved_by"]
legacy_speaker_stores: 5
legacy_ddl_authorities: 4
enforced_foreign_keys: false
check_constraints: 0
project_identifier_path_pattern: <base>/<slug>/.umbra/project.db
artifact_path_patterns:
utterance: .umbra/chapter_audio/ch{N}/utt_{utterance_index:04d}.wav
marker: .umbra/chapter_audio/ch{N}/utt_{utterance_index:04d}.wav.txt
chapter_master: .umbra/chapter_audio/ch{N}_full.wav
chapter_mp3: .umbra/chapter_audio/ch{N}_full.mp3
book_master: .umbra/export/{m4b_meta.output_name}
ar_track: .umbra/export/ar_package/{Base}_{NN}_{Title}.mp3
ar_manifest: .umbra/export/ar_package.json
voice_reference: .umbra/voices/{name_slug}_ref.wav
proposed_tables:
- {name: works, grain: one bibliographic work, tenancy: organization, writer: human_api, pk: uuid, delta: ADD_TABLE}
- {name: source_documents, grain: one ingested file version, tenancy: organization+project, writer: ingestion, pk: uuid, delta: ADD_TABLE}
- {name: source_revisions, grain: one frozen source revision, tenancy: organization+project, writer: source_editor, pk: uuid, delta: ADD_TABLE}
- {name: source_chapters, grain: one chapter per revision, tenancy: organization+project, writer: analysis, pk: uuid, delta: ADD_TABLE}
- {name: source_units, grain: one addressable source unit, tenancy: organization+project, writer: analysis, pk: uuid, delta: ADD_TABLE, note: generated source_unit_key = sha256(source_revision_id||ordinal||occurrence||text_sha256)}
- {name: speakers, grain: one logical speaker per project, tenancy: organization+project, writer: analysis+human, pk: uuid, delta: ADD_TABLE}
- {name: speaker_aliases, grain: one alias per speaker, tenancy: organization+project, writer: analysis+human, pk: uuid, delta: ADD_TABLE}
- {name: analysis_runs, grain: one analysis execution, tenancy: organization+project, writer: worker, pk: uuid, delta: ADD_TABLE}
- {name: analysis_snapshots, grain: one immutable result payload, tenancy: organization+project, writer: worker, pk: uuid, delta: ADD_TABLE}
- {name: voice_profiles, grain: one voice profile per org, tenancy: organization, writer: voice_manager, pk: uuid, delta: ADD_TABLE}
- {name: voice_profile_revisions, grain: one profile change, tenancy: organization, writer: voice_manager, pk: uuid, delta: ADD_TABLE}
- {name: voice_licenses, grain: one licence grant, tenancy: organization, writer: voice_manager, pk: uuid, delta: ADD_TABLE}
- {name: voice_casts, grain: one speaker cast per revision, tenancy: organization+project, writer: audio_director, pk: uuid, delta: ADD_TABLE}
- {name: project_locales, grain: one locale lane per project, tenancy: organization+project, writer: translation, pk: uuid, delta: ADD_TABLE}
- {name: translation_runs, grain: one translation execution, tenancy: organization+project, writer: translation_worker, pk: uuid, delta: ADD_TABLE}
- {name: translation_units, grain: one target render span, tenancy: organization+project, writer: translator, pk: uuid, delta: ADD_TABLE}
- {name: audio_runs, grain: one generation run, tenancy: organization+project, writer: audio_worker, pk: uuid, delta: ADD_TABLE}
- {name: audio_run_casts, grain: one cast row per run+speaker, tenancy: organization+project, writer: audio_worker, pk: uuid, delta: ADD_TABLE}
- {name: artifacts, grain: one immutable artifact version, tenancy: organization+project, writer: producer, pk: uuid, delta: ADD_TABLE, bytes_in_postgres: false}
- {name: artifact_links, grain: one lineage edge, tenancy: organization+project, writer: producer, pk: bigint_identity, delta: ADD_TABLE}
- {name: jobs, grain: one schedulable work unit, tenancy: organization+project, writer: api+worker, pk: uuid, delta: ADD_TABLE, idempotency: "UNIQUE(project_id, job_type, idempotency_key)", key_is_never_a_filename: true}
- {name: job_attempts, grain: one attempt per job, tenancy: organization+project, writer: worker, pk: bigint_identity, delta: ADD_TABLE}
- {name: job_leases, grain: one live lease per job, tenancy: organization+project, writer: worker, pk: uuid, delta: ADD_TABLE, unique: "UNIQUE(job_id) WHERE released_at IS NULL"}
- {name: service_identities, grain: one non-human principal, tenancy: organization_or_platform, writer: platform_admin, pk: uuid, delta: ADD_TABLE}
- {name: legacy_id_map, grain: one legacy key mapping, tenancy: organization+project, writer: migration_tool, pk: uuid, delta: ADD_TABLE}
alters_existing:
- {table: projects, change: "ADD work_id, source_locale, row_version, migration_state, legacy_project_path", delta: ALTER_TABLE}
- {table: users, change: "ADD password_disabled_at", delta: ALTER_TABLE}
- {table: sessions, change: "ADD mfa_verified_at, idle_deadline_at", delta: ALTER_TABLE}
- {table: audit_events, change: "ADD actor_service_identity_id, principal_kind, actor_subject_snapshot, session_id", delta: ALTER_TABLE}
- {table: permissions, change: "ADD missing audio/speaker/cast/analysis/destroy/self-manage keys", delta: BACKFILL}
- {table: audit_events, change: "NO foreign keys on organization_id/project_id - audit must survive deletion", delta: NO_CHANGE}
invariants:
- id: G1
rule: one source ebook has one stable source identity
enforced_by: "UNIQUE (project_id, sha256) on source_documents"
- id: G2
rule: one canonical project contains audiobook and translation work
enforced_by: "NOT NULL project_id + composite FK (project_id, organization_id)"
- id: G3
rule: analysis has immutable versions
enforced_by: "BEFORE UPDATE triggers on source_revisions/analysis_runs/analysis_snapshots"
- id: G4
rule: speaker identity is stable across languages
enforced_by: "speakers.id + source_unit_key; translated names never written to speakers"
- id: G5
rule: voice reuse only when language/accent/consent/licence allow
enforced_by: "deny-by-default trigger on voice_casts + app.voice_licence_covers()"
- id: G6
rule: every translation references source book, project and analysis version
enforced_by: "NOT NULL source_revision_id + nightly reconciliation"
- id: G7
rule: every audiobook references project, translation when applicable, cast snapshot, generation run
enforced_by: "NOT NULL audio_run_id + CHECK + nightly lineage validator"
- id: G8
rule: translated ebooks and audiobooks never become duplicate source records
enforced_by: "artifact_links acyclicity check + application rule"
- id: G9
rule: audio production continues before, during and after cutover
enforced_by: "degraded-mode lease; PG failure logged, never fatal"
- id: G10
rule: large media in object storage only
enforced_by: "no bytea/blob column in any new table; test asserts absence"
- id: G11
rule: every write is idempotent and safe to retry
enforced_by: "natural unique constraints on every table + replay tests"
- id: G12
rule: concurrent workers cannot silently overwrite one another
enforced_by: "partial UNIQUE on job_leases + FOR UPDATE SKIP LOCKED + row_version"
- id: G13
rule: service identities are not arbitrary client-supplied user ids
enforced_by: "CHECK (principal_kind='service' => actor_service_identity_id NOT NULL AND actor_user_id IS NULL)"
- id: G14
rule: translation UI discovers authorized projects without a separate account system
enforced_by: "single app.authorized_projects() used by both clients"
- id: G15
rule: existing staleness semantics preserved, including the fx_version rule
enforced_by: "regression test; generation/staleness.py:98"
identity_contract:
canonical_users_table: users
canonical_projects_table: projects
membership_model: user_roles where scope='project' and status='active'
project_members_table: REJECTED
second_password_store: FORBIDDEN
authentication_entry_point: "app.resolve_session(p_token_hash text) SECURITY DEFINER"
project_context: "derived server-side per request; app.can_access_project(project_id) recomputed every request; never trusted from request"
retired_mechanism: "unsigned active_project browser cookie"
session_absolute_lifetime: 7d
session_idle_lifetime: 8h
session_storage: "sha256(token) only"
session_revoke_all: "app.revoke_user_sessions(uuid) SECURITY DEFINER - NEW"
suspension_enforcement: app.resolve_session
mfa_enforcement: app.resolve_session, requires sessions.mfa_verified_at - NEW
human_principal: users row via sessions
worker_principal: service_identities row + job_leases row
worker_impersonation: PROHIBITED
session_scope: user-global; organisation context re-derived and re-authorised per request
security:
guc_impersonation:
guc: app.user_id
settable_by_any_role: true
revocable: false
rationale: "custom placeholder GUCs are not in pg_settings, so REVOKE SET ON PARAMETER is unavailable"
classification: ACCEPTED_RESIDUAL_RISK
compensating_controls:
- credential in root-owned 0640 file, service-group readable only
- "SET LOCAL inside an explicit transaction by trusted framework code only"
- never derived from request body, query, cookie, header or file (lint-enforced)
- database reachable only by the API
- non-superuser owner role (H24)
- incident-response logging available
superuser_in_data_path: true
force_rls_permitted: false
new_app_functions_public_executable_by_default: true
default_privileges_configured: false
cookie_flags_present: [max_age]
cookie_flags_missing: [httponly, samesite, secure]
server_binding: "0.0.0.0:8420 plain HTTP behind LAN reverse proxy"
application_auth: none
csrf_protection: none
security_headers: none
trusted_host: none
download_authorization: none
verified_application_defects:
- {id: A1, severity: critical, finding: "load_config accepts absolute paths verbatim; cookie is a path-selection primitive", evidence: "umbraaudio/config.py:156-158"}
- {id: A2, severity: critical, finding: "download traversal via string-prefix containment instead of path components", evidence: "umbraaudio/web/routes.py:5049"}
- {id: A3, severity: critical, finding: "upload filename used raw, no Path().name; writes outside uploads/", evidence: "umbraaudio/web/routes.py:633,640,647,652"}
- {id: A4, severity: high, finding: "activate-project existence check is unreachable dead code", evidence: "umbraaudio/web/routes.py:525 with config.py:165"}
- {id: A5, severity: critical, finding: "65 mutating endpoints with zero authorization, including rmtree of voices/ and DELETE FROM utterances", evidence: "umbraaudio/web/routes.py:4674,4711,5097"}
- {id: A6, severity: high, finding: "unauthenticated model load/unload of GB-scale models", evidence: "umbraaudio/web/routes.py:5744,5775,5814"}
- {id: A7, severity: high, finding: "second CWD-relative project resolution path bypasses load_config and its checks", evidence: "umbraaudio/web/__init__.py:51"}
- {id: A8, severity: medium, finding: "unauthenticated outbound-request proxy to dictionaryapi.dev", evidence: "umbraaudio/web/routes.py:1847"}
- {id: A9, severity: medium, finding: "404-vs-403 existence oracle: existence checked before containment", evidence: "umbraaudio/web/routes.py:5047"}
- {id: A10, severity: medium, finding: "ChapterBuilder has no lock; concurrent builds write the same files", evidence: "umbraaudio/web/routes.py:5097-5110"}
cutover:
strategy: per_project_flagged_dual_write
default_repository: SqliteProjectRepository
phase_order: [P0_inventory, P1_adapter_boundary, P2_mirror_backfill_shadow_read,
P3_enterprise_schema, P4_dual_write, P5_dry_run_primary,
P6_enterprise_authoritative, P7_retention]
flag: projects.migration_state
flag_values: [legacy, mirroring, dual_write, enterprise_authoritative, retired]
global_switch: FORBIDDEN
dual_write_failure_policy: log_and_continue
degraded_mode:
lease_validity_hours: 6
on_expiry: halt new runs, complete in-flight rendering, alert operator
authoritative_when:
- three consecutive nightly reconciliations pass
- artifact sha256 all match
- zero mapping_state=unmapped for the project
- one full generation run completed with enterprise store primary
- restore drill passed
rollback:
method: single flag flip
stops_worker: false
deletes_source_data: false
retains_enterprise_rows: true
marks_rows: superseded_by_local
unmappable_records:
table: legacy_id_map
states: [unmapped, ambiguous, rejected]
dropped: false
schema_delta_counts:
add_table: 24
alter_table: 4
add_column: 12
add_constraint: 6
add_index: 30
enable_prove_rls: 21
backfill: 3
grant_revoke: 2
new_service_role: 2
no_change: 2
blockers:
- {id: B1, severity: BLOCKER, title: "worker role can read no project and cannot attribute audit events", evidence: "001:471-473,548-552", fix: [H1, H2, H15, H22], workaround_forbidden: "setting app.user_id for the worker"}
- {id: B2, severity: BLOCKER, title: "no permission exists for audio generation, speaker editing, voice casting, analysis, or state destruction", evidence: "001:378-391", fix: [H19]}
- {id: B3, severity: BLOCKER, title: "mfa_required stored but never enforced; sessions lack mfa_verified_at", evidence: "001:30,120-129", fix: [H17, H20]}
- {id: B4, severity: BLOCKER, title: "new functions in schema app are EXECUTE-able by PUBLIC by default", evidence: "001:580", fix: [H23]}
- {id: B5, severity: BLOCKER, title: "users.status and organizations.status not enforced by any policy", evidence: "001:28-29,15-17", fix: [H20]}
open_decisions:
- {id: DECISION-1, priority: highest, question: "confirm reversal of spec 19 section 2.3 and 16 M4 (one-way import) in favour of an authoritative enterprise database", recommendation: "adopt, and amend spec 19"}
- {id: DECISION-2, question: "confirm spec 18 section 4.2.1 online-backup snapshot protocol is superseded by shared-database reads", recommendation: "adopt; removes snapshot-consistency problem"}
- {id: DECISION-3, question: "org-scoped voice_manager project visibility: metadata-only or full membership", recommendation: "metadata-only"}
- {id: DECISION-4, question: "add audio_engineer and producer roles", recommendation: "defer; project_manager plus audio_director is sufficient"}
- {id: DECISION-5, question: "retention period for artifacts and legacy SQLite after cutover", recommendation: "indefinite read-only"}
- {id: DECISION-6, question: "object storage host and availability", recommendation: "must exist before artifact registration; until then object_key resolves locally"}
- {id: DECISION-7, question: "identity provider: external OIDC or promoted bootstrap provider", recommendation: "external OIDC"}
- {id: DECISION-8, question: "is the audio UI retired in favour of the shared review UI", recommendation: "decide early; determines whether defect fixes are prerequisite or parallel"}
- {id: DECISION-9, question: "phase 2 scope: translation-design tables and commercial voice subsystem", recommendation: "defer to a separate approved phase"}
acceptance_tests: 20
backup_requirements: 7
production_actions_taken: none
next_step: "human review and approval of this contract; no connection, migration, or application data write until approved"
End of contract. No database was contacted, no migration was executed, no production data was written, and no secret was used or requested. Awaiting human approval.