Implement hosted Target Ledger append API (WP-0006-T04)
migrations/0002_ledger.sql adds ledger_entries with an identity-column
sequence for exact append order and no UPDATE/DELETE grant for trf_app.
src/target_revenue/ledger.py: append_entry() rejects caller-supplied
previous_entry_hash/signature, enforces per-Licensor phase ownership,
serializes concurrent appends via pg_advisory_xact_lock, computes the
chain tip and signs with the Trust Service instance's own Ed25519 key
(service/keys.py), reusing validation.py's checks unchanged. Adds
POST/GET /phases/{id}/ledger and an unauthenticated GET /public-key.
Also fixes a route-ordering bug found while wiring this in: phase IDs
never needed the {phase_id:path} converter (they contain colons, not
slashes), and its greedy matching was swallowing /ledger-suffixed
paths into the plain GET /phases/{id} route.
tests/test_ledger_hosting.py (8 Docker-gated tests) exercises hash-chain
linkage, forged-field rejection, cross-Licensor isolation, currency and
duplicate-id rejection, DB-privilege enforcement, signature
verification via the public-key endpoint, and the task's own
highest-priority property: append -> export -> offline fold reproduces
the exact expected Development/Remission Credit and Outstanding Target.
2026-07-29 21:34:27 +02:00
|
|
|
"""FastAPI surface for the hosted Phase Registry, Extension Registry
|
|
|
|
|
(WP-0006-T03), and Target Ledger append API (WP-0006-T04). Metrics (T05)
|
|
|
|
|
and Conversion 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` / `target_revenue.ledger`;
|
|
|
|
|
this file's only job is HTTP framing (status codes, request/response shape,
|
|
|
|
|
bearer-token extraction), not conformance or chaining logic.
|
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
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-29 21:42:52 +02:00
|
|
|
from .. import ledger, metrics, registry
|
Implement hosted Target Ledger append API (WP-0006-T04)
migrations/0002_ledger.sql adds ledger_entries with an identity-column
sequence for exact append order and no UPDATE/DELETE grant for trf_app.
src/target_revenue/ledger.py: append_entry() rejects caller-supplied
previous_entry_hash/signature, enforces per-Licensor phase ownership,
serializes concurrent appends via pg_advisory_xact_lock, computes the
chain tip and signs with the Trust Service instance's own Ed25519 key
(service/keys.py), reusing validation.py's checks unchanged. Adds
POST/GET /phases/{id}/ledger and an unauthenticated GET /public-key.
Also fixes a route-ordering bug found while wiring this in: phase IDs
never needed the {phase_id:path} converter (they contain colons, not
slashes), and its greedy matching was swallowing /ledger-suffixed
paths into the plain GET /phases/{id} route.
tests/test_ledger_hosting.py (8 Docker-gated tests) exercises hash-chain
linkage, forged-field rejection, cross-Licensor isolation, currency and
duplicate-id rejection, DB-privilege enforcement, signature
verification via the public-key endpoint, and the task's own
highest-priority property: append -> export -> offline fold reproduces
the exact expected Development/Remission Credit and Outstanding Target.
2026-07-29 21:34:27 +02:00
|
|
|
from . import keys
|
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
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Implement hosted Target Ledger append API (WP-0006-T04)
migrations/0002_ledger.sql adds ledger_entries with an identity-column
sequence for exact append order and no UPDATE/DELETE grant for trf_app.
src/target_revenue/ledger.py: append_entry() rejects caller-supplied
previous_entry_hash/signature, enforces per-Licensor phase ownership,
serializes concurrent appends via pg_advisory_xact_lock, computes the
chain tip and signs with the Trust Service instance's own Ed25519 key
(service/keys.py), reusing validation.py's checks unchanged. Adds
POST/GET /phases/{id}/ledger and an unauthenticated GET /public-key.
Also fixes a route-ordering bug found while wiring this in: phase IDs
never needed the {phase_id:path} converter (they contain colons, not
slashes), and its greedy matching was swallowing /ledger-suffixed
paths into the plain GET /phases/{id} route.
tests/test_ledger_hosting.py (8 Docker-gated tests) exercises hash-chain
linkage, forged-field rejection, cross-Licensor isolation, currency and
duplicate-id rejection, DB-privilege enforcement, signature
verification via the public-key endpoint, and the task's own
highest-priority property: append -> export -> offline fold reproduces
the exact expected Development/Remission Credit and Outstanding Target.
2026-07-29 21:34:27 +02:00
|
|
|
def get_signing_key():
|
|
|
|
|
if not hasattr(app.state, "signing_key"):
|
|
|
|
|
app.state.signing_key = keys.load_signing_key()
|
|
|
|
|
return app.state.signing_key
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
Implement hosted Target Ledger append API (WP-0006-T04)
migrations/0002_ledger.sql adds ledger_entries with an identity-column
sequence for exact append order and no UPDATE/DELETE grant for trf_app.
src/target_revenue/ledger.py: append_entry() rejects caller-supplied
previous_entry_hash/signature, enforces per-Licensor phase ownership,
serializes concurrent appends via pg_advisory_xact_lock, computes the
chain tip and signs with the Trust Service instance's own Ed25519 key
(service/keys.py), reusing validation.py's checks unchanged. Adds
POST/GET /phases/{id}/ledger and an unauthenticated GET /public-key.
Also fixes a route-ordering bug found while wiring this in: phase IDs
never needed the {phase_id:path} converter (they contain colons, not
slashes), and its greedy matching was swallowing /ledger-suffixed
paths into the plain GET /phases/{id} route.
tests/test_ledger_hosting.py (8 Docker-gated tests) exercises hash-chain
linkage, forged-field rejection, cross-Licensor isolation, currency and
duplicate-id rejection, DB-privilege enforcement, signature
verification via the public-key endpoint, and the task's own
highest-priority property: append -> export -> offline fold reproduces
the exact expected Development/Remission Credit and Outstanding Target.
2026-07-29 21:34:27 +02:00
|
|
|
@app.get("/phases/{phase_id}")
|
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
|
|
|
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
|
Implement hosted Target Ledger append API (WP-0006-T04)
migrations/0002_ledger.sql adds ledger_entries with an identity-column
sequence for exact append order and no UPDATE/DELETE grant for trf_app.
src/target_revenue/ledger.py: append_entry() rejects caller-supplied
previous_entry_hash/signature, enforces per-Licensor phase ownership,
serializes concurrent appends via pg_advisory_xact_lock, computes the
chain tip and signs with the Trust Service instance's own Ed25519 key
(service/keys.py), reusing validation.py's checks unchanged. Adds
POST/GET /phases/{id}/ledger and an unauthenticated GET /public-key.
Also fixes a route-ordering bug found while wiring this in: phase IDs
never needed the {phase_id:path} converter (they contain colons, not
slashes), and its greedy matching was swallowing /ledger-suffixed
paths into the plain GET /phases/{id} route.
tests/test_ledger_hosting.py (8 Docker-gated tests) exercises hash-chain
linkage, forged-field rejection, cross-Licensor isolation, currency and
duplicate-id rejection, DB-privilege enforcement, signature
verification via the public-key endpoint, and the task's own
highest-priority property: append -> export -> offline fold reproduces
the exact expected Development/Remission Credit and Outstanding Target.
2026-07-29 21:34:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/public-key")
|
|
|
|
|
def read_public_key() -> dict[str, str]:
|
|
|
|
|
"""The Ed25519 public key ledger entry signatures verify against.
|
|
|
|
|
|
|
|
|
|
Deliberately unauthenticated: an external verifier must be able to
|
|
|
|
|
check a signature without trusting anything about this API's own
|
|
|
|
|
access control (TrustServicePRD §3 point 2, offline verifiability).
|
|
|
|
|
"""
|
|
|
|
|
return {"algorithm": "ed25519", "public_key_hex": keys.public_key_hex(get_signing_key())}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/phases/{phase_id}/ledger", status_code=201)
|
|
|
|
|
def append_ledger_entry(
|
|
|
|
|
phase_id: str,
|
|
|
|
|
entry: dict[str, Any],
|
|
|
|
|
licensor: registry.Licensor = Depends(get_licensor),
|
|
|
|
|
conn: Connection = Depends(get_connection),
|
|
|
|
|
signing_key=Depends(get_signing_key),
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
return ledger.append_entry(conn, licensor, phase_id, entry, signing_key)
|
|
|
|
|
except registry.RegistrationError as exc:
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/phases/{phase_id}/ledger")
|
|
|
|
|
def read_ledger(
|
|
|
|
|
phase_id: str, conn: Connection = Depends(get_connection)
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
"""Public: ledger facts are public per FR-10 (aggregate figures,
|
|
|
|
|
confidential evidence *references* — not the evidence itself)."""
|
|
|
|
|
if registry.get_phase_manifest(conn, phase_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="phase not found")
|
|
|
|
|
return ledger.get_ledger(conn, phase_id)
|
2026-07-29 21:42:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/phases/{phase_id}/metrics")
|
|
|
|
|
def read_metrics(
|
|
|
|
|
phase_id: str, conn: Connection = Depends(get_connection)
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Public, unauthenticated per FR-9/FR-10 — see `target_revenue.metrics`
|
|
|
|
|
for the fact/calculation/forecast labeling this response preserves."""
|
|
|
|
|
manifest = registry.get_phase_manifest(conn, phase_id)
|
|
|
|
|
if manifest is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="phase not found")
|
|
|
|
|
entries = ledger.get_ledger(conn, phase_id)
|
|
|
|
|
return metrics.compute_metrics(manifest, entries, metrics.utcnow())
|