-- 0008: the audio source import — the controller's copy of what the audio node -- analyzed, and the tables the file-based importer writes. -- -- Status: APPLIED (applied by the audio-controller to `umbratrans` as -- described in the migration procedure; verified against scratch and then -- applied). Proposed alongside `umbratrans_web/audio_import.py` and -- `docs/audio-system-mapping-v1.md`. -- -- Additive and idempotent: four new tables, one guard function, their policies. -- Nothing existing is altered, and no existing table gains or loses a column. -- -- Why these tables exist at all. The audio node owns the audiobooks and exports -- a metadata bundle; the controller is the authoritative repository for the -- translation pipeline and must hold a copy of that analysis rather than reach -- across to the audio database whenever it needs a chapter or a speaker. The -- copy is a snapshot identified by `content_hash`, and the fields are the ones -- `audio_import.py` maps — the mapping document is the contract, this file is -- where it lands. -- -- Three decisions here are load-bearing and are commented where they are made: -- -- 1. `project_id` carries no foreign key. `projects` lives in the per-tenant -- schema (0005) and is created by `app.provision_tenant_schema`, so there is -- no stable relation name to reference. `public.artifacts` reached the same -- conclusion in 0003 and this follows it: `organization_id` is the FK that -- matters for tenancy, and `project_id` is an opaque key scoped by it. -- 2. Identity is the source row id, never the order. `source_row_id` and -- `external_id` come from the audio database; `reading_order` is a snapshot -- of one export and is deliberately not unique or indexed as an identity. -- 3. The four tables are append-only on UPDATE. A source fact whose text is -- rewritten underneath its `source_hash` is a row that no longer describes -- anything; a changed source is a new import, not an edit. DELETE is -- permitted, because re-import and retention both need it and the same -- reasoning as `artifacts` applies. -- -- `integrity` for an artifact is `unverified` on import by design: the importer -- records a render's path and checksum as a claim. Reading the bytes back is a -- different operation with a different name. BEGIN; -- Fail fast rather than half-create if the prerequisites are missing. Both are -- phase-1 objects; if either is absent this is the wrong database. DO $preflight$ BEGIN IF to_regclass('public.organizations') IS NULL THEN RAISE EXCEPTION 'public.organizations is missing; 0008 belongs on the control-plane schema'; END IF; IF NOT EXISTS (SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' AND p.proname = 'is_organization_member') THEN RAISE EXCEPTION 'app.is_organization_member(uuid) is missing; the RLS policies this file writes would be inert'; END IF; END $preflight$; -- --------------------------------------------------------------------- -- 1. The source reference: one row per project, one source system -- --------------------------------------------------------------------- -- The book-level record: what this project came from, and the content hash of -- the whole export. The hash is what makes an import idempotent — the same bytes -- are the same import, and a different hash is a different one. CREATE TABLE IF NOT EXISTS public.project_source_refs ( project_id uuid PRIMARY KEY, organization_id uuid NOT NULL REFERENCES public.organizations(id), -- The exporting system and the node that ran it. Text, not a FK to -- compute_nodes: the source system is a fact about the data (it is -- `umbraaudio` on `umbraaudio-m4`), and a node row can be removed (0006) -- without the provenance of an already-imported book becoming unknown. system text NOT NULL CHECK (length(btrim(system)) BETWEEN 1 AND 64), node_id text NOT NULL, external_id text NOT NULL, source_title text, source_status text, -- Languages the audio side designated, if any. Empty is accepted as an -- answer: target languages are a translation-side decision. languages jsonb NOT NULL DEFAULT '[]'::jsonb, -- The numbering schemes and the hazard that accompanies them, stored -- verbatim because the note ("three numbers identify one chapter and none -- derives from another") is the useful part. numbering jsonb NOT NULL DEFAULT '{}'::jsonb, content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'), product_version text, source_meta jsonb NOT NULL DEFAULT '{}'::jsonb, imported_at timestamptz NOT NULL DEFAULT now(), -- One source system may only describe one project once. A second system (a -- future cover pipeline, say) may add its own row about the same project, so -- the uniqueness is on the pair and not on either alone. CONSTRAINT project_source_refs_system_external_key UNIQUE (system, external_id) ); CREATE INDEX IF NOT EXISTS project_source_refs_org_idx ON public.project_source_refs (organization_id); -- --------------------------------------------------------------------- -- 2. Speakers -- --------------------------------------------------------------------- -- One row per speaker *as the source recorded it*. The narrator is the one -- merge the importer performs, and it is performed on the identity the pipeline -- uses, not here: both narrator source rows are kept, and `merged_into` records -- which spelling they resolve to. Nothing else is merged — a casefold collision -- group is stored and surfaced to a human, never collapsed. CREATE TABLE IF NOT EXISTS public.translation_speakers ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), project_id uuid NOT NULL, organization_id uuid NOT NULL REFERENCES public.organizations(id), external_id text NOT NULL, display_name text NOT NULL, role text NOT NULL CHECK (role IN ('narrator', 'character')), aliases jsonb NOT NULL DEFAULT '[]'::jsonb, collision_group text, has_profile boolean NOT NULL DEFAULT false, is_group boolean NOT NULL DEFAULT false, unidentified boolean NOT NULL DEFAULT false, edge_voice text, tts_engine text, voice_review_status text, -- The canonical narrator this row resolves to, when it is not itself the -- canonical one. NULL for characters and for the canonical narrator. merged_into_external_id text, source_meta jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT translation_speakers_identity_key UNIQUE (project_id, external_id) ); CREATE INDEX IF NOT EXISTS translation_speakers_org_idx ON public.translation_speakers (organization_id); CREATE INDEX IF NOT EXISTS translation_speakers_project_idx ON public.translation_speakers (project_id); -- --------------------------------------------------------------------- -- 3. Chapters -- --------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.translation_chapters ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), project_id uuid NOT NULL, organization_id uuid NOT NULL REFERENCES public.organizations(id), external_id text NOT NULL, -- A snapshot of the export's order. Useful, not an identity. reading_order integer NOT NULL, matter text NOT NULL DEFAULT 'novel' CHECK (matter IN ('novel', 'matter')), title text, source_hash text NOT NULL CHECK (source_hash ~ '^[0-9a-f]{64}$'), source_row_id bigint NOT NULL, numbering_basis jsonb NOT NULL DEFAULT '{}'::jsonb, utterance_count integer NOT NULL DEFAULT 0 CHECK (utterance_count >= 0), source_status text, source_meta jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT translation_chapters_identity_key UNIQUE (project_id, external_id) ); CREATE INDEX IF NOT EXISTS translation_chapters_org_idx ON public.translation_chapters (organization_id); CREATE INDEX IF NOT EXISTS translation_chapters_project_idx ON public.translation_chapters (project_id, reading_order); -- --------------------------------------------------------------------- -- 4. Utterances -- --------------------------------------------------------------------- -- `chapter_external_id` is text and not a FK to `translation_chapters`, for the -- same reason `project_id` has no FK: these rows arrive together in one import -- and the importer has already resolved that every reference resolves. A FK -- would add a constraint the import order has to satisfy without adding a -- guarantee the importer does not already hold. CREATE TABLE IF NOT EXISTS public.translation_utterances ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), project_id uuid NOT NULL, organization_id uuid NOT NULL REFERENCES public.organizations(id), external_id text NOT NULL, chapter_external_id text NOT NULL, utterance_index integer NOT NULL, kind text NOT NULL CHECK (kind IN ('narration', 'dialogue', 'scene_break')), text text NOT NULL, source_hash text NOT NULL CHECK (source_hash ~ '^[0-9a-f]{64}$'), speaker_external_id text NOT NULL, -- NULL is the implicit narrator, and that is the whole meaning of NULL here. -- An explicit attribution always carries the name the source wrote. speaker_name_raw text, speaker_name_resolved text NOT NULL DEFAULT '', attribution text NOT NULL CHECK (attribution IN ('implicit_narrator', 'explicit')), needs_review boolean NOT NULL DEFAULT false, render_external_id text, source_row_id bigint, source_meta jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT translation_utterances_identity_key UNIQUE (project_id, external_id) ); CREATE INDEX IF NOT EXISTS translation_utterances_org_idx ON public.translation_utterances (organization_id); CREATE INDEX IF NOT EXISTS translation_utterances_chapter_idx ON public.translation_utterances (project_id, chapter_external_id, utterance_index); CREATE INDEX IF NOT EXISTS translation_utterances_speaker_idx ON public.translation_utterances (project_id, speaker_external_id); -- --------------------------------------------------------------------- -- 5. Append-only on UPDATE -- --------------------------------------------------------------------- -- One guard for the four tables. UPDATE is refused because a source fact is a -- statement about a document at a moment, and rewriting it in place breaks the -- link between the row and its `source_hash`. DELETE is permitted, exactly as it -- is for `artifacts`: re-importing a changed bundle and reclaiming storage both -- need it, and the guarantee that matters (no silent edit) is not weakened by -- allowing a row to be removed and written again. DO $guard$ BEGIN EXECUTE $func$ CREATE OR REPLACE FUNCTION app.prevent_translation_source_mutation() RETURNS trigger LANGUAGE plpgsql AS $body$ BEGIN RAISE EXCEPTION 'translation source rows are a snapshot and are append-only; a changed source is a new import, not an edit'; END; $body$; $func$; EXECUTE 'DROP TRIGGER IF EXISTS project_source_refs_append_only ON public.project_source_refs'; EXECUTE 'CREATE TRIGGER project_source_refs_append_only BEFORE UPDATE ON public.project_source_refs FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()'; EXECUTE 'DROP TRIGGER IF EXISTS translation_speakers_append_only ON public.translation_speakers'; EXECUTE 'CREATE TRIGGER translation_speakers_append_only BEFORE UPDATE ON public.translation_speakers FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()'; EXECUTE 'DROP TRIGGER IF EXISTS translation_chapters_append_only ON public.translation_chapters'; EXECUTE 'CREATE TRIGGER translation_chapters_append_only BEFORE UPDATE ON public.translation_chapters FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()'; EXECUTE 'DROP TRIGGER IF EXISTS translation_utterances_append_only ON public.translation_utterances'; EXECUTE 'CREATE TRIGGER translation_utterances_append_only BEFORE UPDATE ON public.translation_utterances FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()'; EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_translation_source_mutation() FROM PUBLIC'; IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_api') THEN EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_translation_source_mutation() TO umbracontrol_api'; END IF; IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_worker') THEN EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_translation_source_mutation() TO umbracontrol_worker'; END IF; END $guard$; -- --------------------------------------------------------------------- -- 6. Row-level security -- --------------------------------------------------------------------- -- Enabled but not forced, matching every other table in this schema: the -- application owns these tables, so the policy is defence in depth for a -- non-owner role and the adapter's own scoping is today's control. `FORCE` and a -- non-owner application role arrive together at gate G2 and nowhere sooner. -- -- `CREATE POLICY` has no `IF NOT EXISTS`, so a re-run would fail on the second -- apply. Each is dropped first, which is what makes this file safe to apply -- twice. DO $rls$ DECLARE target text; BEGIN FOREACH target IN ARRAY ARRAY[ 'project_source_refs', 'translation_speakers', 'translation_chapters', 'translation_utterances' ] LOOP EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', target); EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', target || '_tenant', target); END LOOP; EXECUTE 'CREATE POLICY project_source_refs_tenant ON public.project_source_refs USING (app.is_platform_admin() OR app.is_organization_member(organization_id))'; EXECUTE 'CREATE POLICY translation_speakers_tenant ON public.translation_speakers USING (app.is_platform_admin() OR app.is_organization_member(organization_id))'; EXECUTE 'CREATE POLICY translation_chapters_tenant ON public.translation_chapters USING (app.is_platform_admin() OR app.is_organization_member(organization_id))'; EXECUTE 'CREATE POLICY translation_utterances_tenant ON public.translation_utterances USING (app.is_platform_admin() OR app.is_organization_member(organization_id))'; END $rls$; -- --------------------------------------------------------------------- -- 7. Documentation for the schema itself -- --------------------------------------------------------------------- COMMENT ON TABLE public.project_source_refs IS 'One row per project per source system: what the project was imported from and the content hash of that import.'; COMMENT ON COLUMN public.project_source_refs.content_hash IS 'sha256 of the exported manifest bytes. The same bytes are the same import; a different hash is a different import.'; COMMENT ON TABLE public.translation_speakers IS 'Speakers as the source recorded them. The narrator merge is recorded in merged_into_external_id; no other row is merged.'; COMMENT ON TABLE public.translation_chapters IS 'Chapters. Identity is external_id (the source row id); reading_order is a snapshot and not an identity.'; COMMENT ON TABLE public.translation_utterances IS 'Utterances. speaker_name_raw NULL means the implicit narrator, which is the entire meaning of NULL in that column.'; COMMENT ON FUNCTION app.prevent_translation_source_mutation() IS 'Refuses UPDATE on the translation source tables. A changed source is a new import, not an edit.'; COMMIT; -- --------------------------------------------------------------------- -- Rollback, in reverse dependency order. Not applied automatically. -- -- DROP POLICY IF EXISTS translation_utterances_tenant ON public.translation_utterances; -- DROP POLICY IF EXISTS translation_chapters_tenant ON public.translation_chapters; -- DROP POLICY IF EXISTS translation_speakers_tenant ON public.translation_speakers; -- DROP POLICY IF EXISTS project_source_refs_tenant ON public.project_source_refs; -- DROP TRIGGER IF EXISTS translation_utterances_append_only ON public.translation_utterances; -- DROP TRIGGER IF EXISTS translation_chapters_append_only ON public.translation_chapters; -- DROP TRIGGER IF EXISTS translation_speakers_append_only ON public.translation_speakers; -- DROP TRIGGER IF EXISTS project_source_refs_append_only ON public.project_source_refs; -- DROP FUNCTION IF EXISTS app.prevent_translation_source_mutation(); -- DROP TABLE IF EXISTS public.translation_utterances; -- DROP TABLE IF EXISTS public.translation_chapters; -- DROP TABLE IF EXISTS public.translation_speakers; -- DROP TABLE IF EXISTS public.project_source_refs; -- -- The rollback is destructive and drops the imported data with the tables. It is -- safe only while nothing the translation pipeline produced points at a -- translation row, which is true today because the pipeline has not run. Once a -- translated chapter exists, the rollback must be replaced by a deprecation, not -- run as written.