target-revenue/migrations/0001_registries.sql

103 lines
4.3 KiB
MySQL
Raw Normal View History

-- 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;