From 7e0c62a8b5ec77cb8a3316113e01c0084191d60a Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 29 Jul 2026 21:03:52 +0200 Subject: [PATCH] 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. --- README.md | 2 +- migrations/0001_registries.sql | 102 ++++++++++ pyproject.toml | 10 + src/target_revenue/registry.py | 160 ++++++++++++++++ src/target_revenue/service/__init__.py | 3 + src/target_revenue/service/app.py | 99 ++++++++++ tests/test_registry_hosting.py | 181 ++++++++++++++++++ ...EV-WP-0006-trust-service-implementation.md | 25 ++- 8 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 migrations/0001_registries.sql create mode 100644 src/target_revenue/registry.py create mode 100644 src/target_revenue/service/__init__.py create mode 100644 src/target_revenue/service/app.py create mode 100644 tests/test_registry_hosting.py diff --git a/README.md b/README.md index 25b6a3b..f8bbeac 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule** | [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 | | [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) | | [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) | -| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD) done, see `specs/TrustServiceProductRequirementsDocument.md`; T02 (stack ADR) next | +| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (Phase/Extension Registry hosting) done; T04 (Ledger append API) next | | [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started | | [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 | diff --git a/migrations/0001_registries.sql b/migrations/0001_registries.sql new file mode 100644 index 0000000..ea00f62 --- /dev/null +++ b/migrations/0001_registries.sql @@ -0,0 +1,102 @@ +-- 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; diff --git a/pyproject.toml b/pyproject.toml index 31297b7..1e91de6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,16 @@ dependencies = [ dev = [ "pytest>=8.0", ] +service = [ + "psycopg[binary]>=3.1", + "psycopg-pool>=3.1", + "fastapi>=0.110", + "uvicorn>=0.27", +] +service-dev = [ + "target-revenue[service,dev]", + "httpx>=0.27", +] [tool.hatch.build.targets.wheel] packages = ["src/target_revenue"] diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py new file mode 100644 index 0000000..eae469c --- /dev/null +++ b/src/target_revenue/registry.py @@ -0,0 +1,160 @@ +"""Hosted Phase Registry and Extension Registry (WP-0006-T03). + +Wraps `validation.py`'s already-tested conformance checks with Postgres +persistence, per `docs/adr/ADR-0002-hosted-trust-service-stack.md`. This +module adds multi-tenancy and storage; it must not introduce new validation +logic beyond what `validation.py` already does (TSD §4.1: this task "adds +multi-tenancy and persistence, not new validation logic"). + +Append-only enforcement for `phase_manifests`, and canonicalization-as- +governance-action for `extensions`, are primarily enforced at the database +level (see `migrations/0001_registries.sql`) — this module's job is to +surface a conformance rejection *before* attempting a write that the +database would reject anyway, so callers get a field-by-field diff instead +of an opaque database error. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from psycopg import Connection +from psycopg.errors import UniqueViolation +from psycopg.types.json import Jsonb + +from . import validation + + +class RegistrationError(ValueError): + """Raised with the specific reason a registration was rejected.""" + + +@dataclass(frozen=True) +class Licensor: + token: str + licensor_id: str + + +def authenticate(conn: Connection, token: str) -> Licensor: + """Resolve a bearer token to a Licensor, or raise RegistrationError. + + Per ADR-0002: write access is scoped per Licensor via this token, kept + independent of the per-entry Ed25519 signing WP-0002 already + implements (that signing happens at the Ledger layer, T04 — this + function only answers "who is calling," not "is this entry authentic"). + """ + row = conn.execute( + "SELECT licensor_id FROM licensors WHERE token = %s", (token,) + ).fetchone() + if row is None: + raise RegistrationError("unrecognized or revoked token") + return Licensor(token=token, licensor_id=row[0]) + + +def register_phase_manifest( + conn: Connection, licensor: Licensor, manifest: dict[str, Any] +) -> None: + """Validate and persist a Phase Manifest. + + Rejects, with the same field-by-field diff `validation.py` produces + offline, rather than silently accepting a non-conformant manifest + (TSD §3.1 validation rule). A manifest is inserted once; there is no + update path — `phase_manifests` grants no UPDATE/DELETE to the + application role (migrations/0001_registries.sql), so a second attempt + to register the same `phase_id` fails at the database's unique + constraint, surfaced here as RegistrationError rather than a raw + IntegrityError. + """ + try: + validation.validate_phase_manifest(manifest) + except validation.ConformanceError as exc: + raise RegistrationError( + "manifest rejected: " + "; ".join(exc.errors) + ) from exc + + phase_id = manifest["phase"]["id"] + try: + conn.execute( + """ + INSERT INTO phase_manifests (phase_id, licensor_id, manifest) + VALUES (%s, %s, %s) + """, + (phase_id, licensor.licensor_id, Jsonb(manifest)), + ) + except UniqueViolation as exc: + raise RegistrationError( + f"phase_id {phase_id!r} is already registered; " + "a Phase Manifest cannot be re-registered or replaced in place" + ) from exc + + +def get_phase_manifest(conn: Connection, phase_id: str) -> dict[str, Any] | None: + row = conn.execute( + "SELECT manifest FROM phase_manifests WHERE phase_id = %s", (phase_id,) + ).fetchone() + return row[0] if row else None + + +def register_extension( + conn: Connection, licensor: Licensor, extension: dict[str, Any] +) -> None: + """Validate and persist a Monetization Extension registration. + + New registrations always start at `status: registered` (assigned here, + never accepted as an input field from the caller) — `canonical` is a + separate, governance-gated transition (TS-FR-2; see + `promote_extension_canonical` below). + """ + try: + validation.validate_extension_contract(extension) + except validation.ConformanceError as exc: + raise RegistrationError( + "extension rejected: " + "; ".join(exc.errors) + ) from exc + + extension_id = extension["id"] + version = extension["version"] + try: + conn.execute( + """ + INSERT INTO extensions (extension_id, version, licensor_id, contract, status) + VALUES (%s, %s, %s, %s, 'registered') + """, + (extension_id, version, licensor.licensor_id, Jsonb(extension)), + ) + except UniqueViolation as exc: + raise RegistrationError( + f"extension {extension_id!r}@{version!r} is already registered" + ) from exc + + +def get_extension( + conn: Connection, extension_id: str, version: str +) -> dict[str, Any] | None: + row = conn.execute( + "SELECT contract, status FROM extensions WHERE extension_id = %s AND version = %s", + (extension_id, version), + ).fetchone() + if row is None: + return None + contract, status = row + return {**contract, "status": status} + + +def promote_extension_canonical( + conn: Connection, extension_id: str, version: str, approved_by: str +) -> None: + """Promote an extension from `registered` to `canonical`. + + Calls the database's `set_extension_status` function rather than an + UPDATE, because the application role has no UPDATE grant on + `extensions` at all (migrations/0001_registries.sql) — canonicalization + is a documented human/governance action recorded with who performed it, + never a route ordinary application code can take by itself + (TSD §4.1: "never automated"). + """ + conn.execute( + "SELECT set_extension_status(%s, %s, %s, %s)", + (extension_id, version, "canonical", approved_by), + ) diff --git a/src/target_revenue/service/__init__.py b/src/target_revenue/service/__init__.py new file mode 100644 index 0000000..6e40fb7 --- /dev/null +++ b/src/target_revenue/service/__init__.py @@ -0,0 +1,3 @@ +"""Hosted Trust Service (WP-0006). Thin ASGI layer over `target_revenue`'s +already-tested schema, validation, and registry logic — see ADR-0002 for +why this stays thin rather than reimplementing that logic.""" diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py new file mode 100644 index 0000000..337f043 --- /dev/null +++ b/src/target_revenue/service/app.py @@ -0,0 +1,99 @@ +"""FastAPI surface for the hosted Phase Registry and Extension Registry +(WP-0006-T03). Only registration and read endpoints live here — the Target +Ledger append API (T04), Metrics (T05), and Attestation (T06) are separate +components per `specs/TechnicalSpecificationDocument.md` §4.1 and are not +implemented in this module. + +Every route delegates to `target_revenue.registry`; this file's only job is +HTTP framing (status codes, request/response shape) and reading the bearer +token, not conformance logic. +""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from psycopg import Connection +from psycopg_pool import ConnectionPool + +from .. import registry + +app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0") + +_DATABASE_URL_ENV = "TRF_DATABASE_URL" + + +def get_pool() -> ConnectionPool: + if not hasattr(app.state, "pool"): + dsn = os.environ.get(_DATABASE_URL_ENV) + if not dsn: + raise RuntimeError(f"{_DATABASE_URL_ENV} is not set") + app.state.pool = ConnectionPool(dsn, min_size=1, max_size=5, open=True) + return app.state.pool + + +def get_connection(): + pool = get_pool() + with pool.connection() as conn: + yield conn + + +def get_licensor(request: Request, conn: Connection = Depends(get_connection)) -> registry.Licensor: + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = auth.split(" ", 1)[1].strip() + try: + return registry.authenticate(conn, token) + except registry.RegistrationError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + + +@app.post("/phases", status_code=201) +def register_phase( + manifest: dict[str, Any], + licensor: registry.Licensor = Depends(get_licensor), + conn: Connection = Depends(get_connection), +) -> dict[str, str]: + try: + registry.register_phase_manifest(conn, licensor, manifest) + except registry.RegistrationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return {"phase_id": manifest["phase"]["id"], "status": "registered"} + + +@app.get("/phases/{phase_id:path}") +def read_phase(phase_id: str, conn: Connection = Depends(get_connection)) -> dict[str, Any]: + manifest = registry.get_phase_manifest(conn, phase_id) + if manifest is None: + raise HTTPException(status_code=404, detail="phase not found") + return manifest + + +@app.post("/extensions", status_code=201) +def register_extension( + extension: dict[str, Any], + licensor: registry.Licensor = Depends(get_licensor), + conn: Connection = Depends(get_connection), +) -> dict[str, str]: + try: + registry.register_extension(conn, licensor, extension) + except registry.RegistrationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return { + "extension_id": extension["id"], + "version": extension["version"], + "status": "registered", + } + + +@app.get("/extensions/{extension_id}/{version}") +def read_extension( + extension_id: str, version: str, conn: Connection = Depends(get_connection) +) -> dict[str, Any]: + result = registry.get_extension(conn, extension_id, version) + if result is None: + raise HTTPException(status_code=404, detail="extension not found") + return result diff --git a/tests/test_registry_hosting.py b/tests/test_registry_hosting.py new file mode 100644 index 0000000..b117374 --- /dev/null +++ b/tests/test_registry_hosting.py @@ -0,0 +1,181 @@ +"""Integration tests for WP-0006-T03 (hosted Phase/Extension Registry). + +Spins up an ephemeral, disposable PostgreSQL container via `docker run` +(never the shared `infra-postgres-1`/`custodian` instance used by the state +hub), applies `migrations/0001_registries.sql`, and exercises the hosted +API through FastAPI's TestClient. Skipped automatically if Docker is not +available, so the offline WP-0002 suite (`tests/test_*.py`) is unaffected. + +Requires the `service-dev` extra: `pip install -e ".[service-dev]"`. +""" + +from __future__ import annotations + +import shutil +import subprocess +import time +import uuid + +import pytest + +psycopg = pytest.importorskip("psycopg") +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from conftest import golden_extension, golden_manifest # noqa: E402 + +REPO_ROOT_MIGRATIONS = ( + __import__("pathlib").Path(__file__).resolve().parents[1] / "migrations" / "0001_registries.sql" +) + +pytestmark = pytest.mark.skipif( + shutil.which("docker") is None, reason="docker not available" +) + + +@pytest.fixture(scope="module") +def pg_container(): + name = f"trf-test-pg-{uuid.uuid4().hex[:8]}" + subprocess.run( + [ + "docker", "run", "--rm", "-d", + "--name", name, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", "POSTGRES_DB=target_revenue_test", + "-p", "127.0.0.1::5432", + "postgres:16-alpine", + ], + check=True, capture_output=True, + ) + try: + port_out = subprocess.run( + ["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True + ).stdout.strip() + host_port = port_out.split(":")[-1] + dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres" + + for _ in range(60): + try: + with psycopg.connect(dsn, connect_timeout=1): + break + except psycopg.OperationalError: + time.sleep(0.5) + else: + raise RuntimeError("postgres container did not become ready in time") + + with psycopg.connect(dsn) as conn: + conn.execute(REPO_ROOT_MIGRATIONS.read_text(encoding="utf-8")) + conn.commit() + token = "test-token-acme" + conn.execute( + "INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)", + (token, "acme-corp"), + ) + conn.commit() + + app_dsn = ( + f"host=127.0.0.1 port={host_port} dbname=target_revenue_test " + f"user=trf_app password=changeme-in-deployment" + ) + yield {"admin_dsn": dsn, "app_dsn": app_dsn, "token": token} + finally: + subprocess.run(["docker", "stop", name], capture_output=True) + + +@pytest.fixture() +def client(pg_container, monkeypatch): + monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"]) + from target_revenue.service import app as app_module + + if hasattr(app_module.app.state, "pool"): + app_module.app.state.pool.close() + del app_module.app.state.pool + with TestClient(app_module.app) as c: + yield c + + +def auth_headers(pg_container): + return {"Authorization": f"Bearer {pg_container['token']}"} + + +def test_register_and_read_golden_manifest(client, pg_container): + manifest = golden_manifest() + resp = client.post("/phases", json=manifest, headers=auth_headers(pg_container)) + assert resp.status_code == 201, resp.text + assert resp.json()["status"] == "registered" + + read = client.get(f"/phases/{manifest['phase']['id']}") + assert read.status_code == 200 + assert read.json() == manifest + + +def test_reject_non_conformant_manifest(client, pg_container): + broken = golden_manifest() + del broken["phase"]["initial_target"] + resp = client.post("/phases", json=broken, headers=auth_headers(pg_container)) + assert resp.status_code == 422 + assert "initial_target" in resp.json()["detail"] + + +def test_cannot_reregister_same_phase_id(client, pg_container): + manifest = golden_manifest() + manifest["phase"]["id"] = manifest["phase"]["id"] + "-dup-test" + first = client.post("/phases", json=manifest, headers=auth_headers(pg_container)) + assert first.status_code == 201 + second = client.post("/phases", json=manifest, headers=auth_headers(pg_container)) + assert second.status_code == 422 + assert "already registered" in second.json()["detail"] + + +def test_unknown_token_rejected(client): + manifest = golden_manifest() + resp = client.post( + "/phases", json=manifest, headers={"Authorization": "Bearer not-a-real-token"} + ) + assert resp.status_code == 401 + + +def test_register_extension_and_promote_canonical(client, pg_container): + ext = golden_extension("development-license") + resp = client.post("/extensions", json=ext, headers=auth_headers(pg_container)) + assert resp.status_code == 201, resp.text + assert resp.json()["status"] == "registered" + + read = client.get(f"/extensions/{ext['id']}/{ext['version']}") + assert read.json()["status"] == "registered" + + import psycopg as _psycopg + from target_revenue import registry as registry_module + + with _psycopg.connect(pg_container["admin_dsn"]) as admin_conn: + registry_module.promote_extension_canonical( + admin_conn, ext["id"], ext["version"], approved_by="Bernd" + ) + admin_conn.commit() + + read_after = client.get(f"/extensions/{ext['id']}/{ext['version']}") + assert read_after.json()["status"] == "canonical" + + +def test_application_role_cannot_update_or_delete_settled_manifest(pg_container): + """Database-level enforcement check (ADR-0002 compensating guardrail 3): + the trf_app role must have no UPDATE/DELETE grant on phase_manifests at + all, independent of what the API layer happens to expose.""" + with psycopg.connect(pg_container["app_dsn"]) as conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute( + "UPDATE phase_manifests SET manifest = manifest WHERE phase_id = 'nonexistent'" + ) + conn.rollback() + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute("DELETE FROM phase_manifests WHERE phase_id = 'nonexistent'") + conn.rollback() + + +def test_application_role_cannot_update_extensions_directly(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute( + "UPDATE extensions SET status = 'canonical' WHERE extension_id = 'nonexistent'" + ) + conn.rollback() diff --git a/workplans/TREV-WP-0006-trust-service-implementation.md b/workplans/TREV-WP-0006-trust-service-implementation.md index 733f139..e956ec8 100644 --- a/workplans/TREV-WP-0006-trust-service-implementation.md +++ b/workplans/TREV-WP-0006-trust-service-implementation.md @@ -108,7 +108,7 @@ thin HTTP layer. T03–T08 may now build against this decision. ```task id: TREV-WP-0006-T03 -status: todo +status: done priority: high state_hub_task_id: "fb9f00e5-5755-44e4-ad07-ba0dc73596b8" ``` @@ -120,6 +120,29 @@ than one golden fixture. Reject non-conformant manifests/extensions at registration, exactly as the Stage 0 validators already do — this task adds multi-tenancy and persistence, not new validation logic. +**Result:** Implemented per ADR-0002. `migrations/0001_registries.sql` +creates `licensors`, `phase_manifests`, `extensions` tables, a +`trf_app` role with **no UPDATE/DELETE grant** on `phase_manifests` or +`extensions` (database-enforced append-only, not just application +convention), and a `set_extension_status()` SECURITY DEFINER function as +the only sanctioned path to `canonical`/`deprecated` — canonicalization is +therefore a recorded, attributable governance action the application role +cannot perform via ordinary UPDATE. `src/target_revenue/registry.py` wraps +the existing `validation.py` checks (no new validation logic) with +persistence and per-Licensor token authentication. `src/target_revenue/service/app.py` +exposes a thin FastAPI surface (`POST/GET /phases`, `POST/GET /extensions`). +Added a `service`/`service-dev` optional-dependency group +(`pyproject.toml`) so the core offline library keeps zero new hard +dependencies. `tests/test_registry_hosting.py` (7 tests, requires Docker, +auto-skips otherwise) spins an ephemeral, disposable Postgres container — +never the shared state-hub `infra-postgres-1`/`custodian` instance — +covering registration, rejection with diff, duplicate-`phase_id` rejection, +unknown-token rejection, extension canonicalization via the governance +function, and two explicit database-privilege tests proving `trf_app` +cannot UPDATE/DELETE `phase_manifests` or UPDATE `extensions` directly. +Original 36-test offline suite verified unchanged and passing with plain +system Python (no service deps required). + ## Hosted Target Ledger append API ```task