target-revenue/migrations/0001_registries.sql
tegwick 7e0c62a8b5 Implement hosted Phase/Extension Registry (WP-0006-T03)
Adds migrations/0001_registries.sql (licensors, phase_manifests,
extensions tables; trf_app role with no UPDATE/DELETE grant on either
table, canonicalization only via a SECURITY DEFINER function), and
src/target_revenue/registry.py + service/app.py: a thin FastAPI layer
wrapping the existing validation.py checks with persistence and
per-Licensor token auth, adding no new validation logic per ADR-0002.
New optional service/service-dev dependency groups keep the core
offline library dependency-free. tests/test_registry_hosting.py (7
tests, Docker-gated, auto-skip otherwise) spins an ephemeral disposable
Postgres container and verifies registration, rejection, duplicate/
unknown-token handling, extension canonicalization, and two explicit
database-privilege checks that the app role cannot bypass the
append-only/governance-gated guarantees.
2026-07-29 21:03:52 +02:00

102 lines
4.3 KiB
PL/PgSQL

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