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.
This commit is contained in:
tegwick 2026-07-29 21:03:52 +02:00
parent e8e8629efd
commit 7e0c62a8b5
8 changed files with 580 additions and 2 deletions

View file

@ -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),
)

View file

@ -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."""

View file

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