-- WP-0006-T03: Phase Registry and Extension Registry hosting. -- Enforces ADR-0002's storage decision at the database level, not merely by -- application convention: settled Phase Manifests are append-only; an -- Extension's canonicalization is a governance action that a plain -- application role cannot perform via UPDATE. -- -- This migration is idempotent (safe to re-run) and assumes it runs against -- a dedicated database (e.g. `target_revenue`), not a shared instance's -- default database. It does not assume, and must not be pointed at, the -- state hub's own `custodian` database. BEGIN; CREATE TABLE IF NOT EXISTS licensors ( token text PRIMARY KEY, licensor_id text NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now() ); -- One row per Phase, keyed by the schema's own globally unique phase.id. -- Per TSD §3.1: phase.id and phase.initial_target.amount are immutable -- after first publication except through an explicit, versioned correction -- record — which Stage 0 has no type for yet (validation.py's -- check_manifest_immutability flags any change as an error). Consequently -- this table has no supported update path at all: a Phase Manifest is -- inserted once and never changed by this component. CREATE TABLE IF NOT EXISTS phase_manifests ( phase_id text PRIMARY KEY, licensor_id text NOT NULL REFERENCES licensors(licensor_id), manifest jsonb NOT NULL, registered_at timestamptz NOT NULL DEFAULT now() ); -- Extension registrations. `status` starts at 'registered' (conformance -- passed) and may only become 'canonical' or 'deprecated' through the -- canonicalize_extension()/deprecate_extension() functions below — never a -- direct UPDATE by the application role (TSD §4.1: "never automated"). CREATE TABLE IF NOT EXISTS extensions ( extension_id text NOT NULL, version text NOT NULL, licensor_id text NOT NULL REFERENCES licensors(licensor_id), contract jsonb NOT NULL, status text NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'canonical', 'deprecated')), registered_at timestamptz NOT NULL DEFAULT now(), status_changed_by text, status_changed_at timestamptz, PRIMARY KEY (extension_id, version) ); -- Governance-only status transition. SECURITY DEFINER so it can run with -- the owning role's privilege even though the calling application role has -- no UPDATE grant on extensions.status itself (see grants below). CREATE OR REPLACE FUNCTION set_extension_status( p_extension_id text, p_version text, p_new_status text, p_changed_by text ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN IF p_new_status NOT IN ('canonical', 'deprecated') THEN RAISE EXCEPTION 'set_extension_status only permits canonical/deprecated, got %', p_new_status; END IF; UPDATE extensions SET status = p_new_status, status_changed_by = p_changed_by, status_changed_at = now() WHERE extension_id = p_extension_id AND version = p_version; IF NOT FOUND THEN RAISE EXCEPTION 'no extension %/%', p_extension_id, p_version; END IF; END; $$; -- Application role: adjust the name to match the deployment's actual role. -- Created here (idempotent) rather than assumed to pre-exist, so this -- migration is self-contained for a fresh `target_revenue` database. DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'trf_app') THEN CREATE ROLE trf_app LOGIN PASSWORD 'changeme-in-deployment'; END IF; END $$; GRANT SELECT, INSERT ON licensors TO trf_app; GRANT SELECT, INSERT ON phase_manifests TO trf_app; -- Deliberately no UPDATE, no DELETE on phase_manifests for trf_app: this is -- the database-level enforcement ADR-0002 requires for append-only Phase -- Manifests, not merely an API design intention. GRANT SELECT, INSERT ON extensions TO trf_app; -- Deliberately no UPDATE, no DELETE on extensions for trf_app either — the -- only sanctioned status transition is via set_extension_status(), a -- SECURITY DEFINER function, so canonicalization is always a recorded, -- attributable governance action, never a route the application's own -- ordinary write path can take. GRANT EXECUTE ON FUNCTION set_extension_status(text, text, text, text) TO trf_app; COMMIT;