target-revenue/src/target_revenue/service/app.py
tegwick 28f0f429b2 Implement hosted Conversion Attestation publication (WP-0006-T06)
migrations/0003_attestations.sql: one row per Phase, no UPDATE/DELETE
grant for trf_app (a published attestation is permanent).

src/target_revenue/attestation.py: publish_attestation() is idempotent
(existing row returned unchanged, never regenerated) and derives
conversion_timestamp from the ledger itself via _find_conversion_prefix(),
which finds the earliest prefix where the fold reaches Outstanding
Target = 0 rather than trusting entries[-1] or wall-clock publish time -
so a later unrelated entry can never change an already-converged Phase's
recorded conversion moment. Raises NotConvertedError rather than
fabricating a conversion. Reuses conversion.generate_attestation()
unchanged. Guarded the psycopg import behind TYPE_CHECKING so the pure
helper stays testable under plain system Python.

service/app.py adds GET /phases/{id}/attestation (unauthenticated,
publish-on-first-observation).

tests/test_attestation.py (3 tests, no Docker/Postgres) proves the
earliest-crossing behavior. 6 new Docker-gated tests in
test_ledger_hosting.py cover pre-conversion 404, the core
legal-technical property that /metrics facts/calculations/forecasts
are identical whether or not /attestation was ever called, one-time
publication, signature verification, and DB-level UPDATE/DELETE
rejection.
2026-07-29 21:50:02 +02:00

176 lines
6.5 KiB
Python

"""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.
"""
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 attestation, ledger, metrics, registry
from . import keys
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_signing_key():
if not hasattr(app.state, "signing_key"):
app.state.signing_key = keys.load_signing_key()
return app.state.signing_key
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}")
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
@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)
@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())
@app.get("/phases/{phase_id}/attestation")
def read_attestation(
phase_id: str,
conn: Connection = Depends(get_connection),
signing_key=Depends(get_signing_key),
) -> dict[str, Any]:
"""Public, unauthenticated. Publishes (idempotently) the first time a
conversion is observed, and simply returns the already-published
record on every call after that — never regenerates, never requires
this call to have happened for conversion to already be legally true
(see target_revenue.attestation's module docstring)."""
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)
try:
return attestation.publish_attestation(conn, manifest, entries, signing_key)
except attestation.NotConvertedError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc