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.
This commit is contained in:
tegwick 2026-07-29 21:34:27 +02:00
parent a419178c08
commit 5064815e77
7 changed files with 567 additions and 12 deletions

View file

@ -1,12 +1,12 @@
"""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.
"""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`; this file's only job is
HTTP framing (status codes, request/response shape) and reading the bearer
token, not conformance logic.
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
@ -18,7 +18,8 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from psycopg import Connection
from psycopg_pool import ConnectionPool
from .. import registry
from .. import ledger, registry
from . import keys
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
@ -34,6 +35,12 @@ def get_pool() -> ConnectionPool:
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:
@ -64,7 +71,7 @@ def register_phase(
return {"phase_id": manifest["phase"]["id"], "status": "registered"}
@app.get("/phases/{phase_id:path}")
@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:
@ -97,3 +104,39 @@ def read_extension(
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)

View file

@ -0,0 +1,42 @@
"""Trust Service instance signing key.
Per ADR-0002: the Ed25519 signature over each Ledger entry is a guarantee
independent of the per-Licensor API token it is what an external party
verifies without trusting the token/access-control layer at all (TSD §3.2,
TrustServicePRD §3 point 2). This module loads that instance key from
`TRF_SIGNING_KEY_HEX` (a 32-byte hex-encoded Ed25519 seed) or, if unset,
generates an ephemeral one for local development/testing only an
ephemeral key means every restart invalidates prior signatures' verifiable
association with "this instance," which is fine for a dev loop and not
acceptable for any real deployment.
"""
from __future__ import annotations
import os
import warnings
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
_ENV_VAR = "TRF_SIGNING_KEY_HEX"
def load_signing_key() -> Ed25519PrivateKey:
hex_seed = os.environ.get(_ENV_VAR)
if hex_seed:
return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(hex_seed))
warnings.warn(
f"{_ENV_VAR} not set — generating an ephemeral signing key. "
"Do not use this in any deployment where signatures must remain "
"verifiable across restarts.",
stacklevel=2,
)
return Ed25519PrivateKey.generate()
def public_key_hex(private_key: Ed25519PrivateKey) -> str:
public_key: Ed25519PublicKey = private_key.public_key()
return public_key.public_bytes_raw().hex()