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

@ -0,0 +1,132 @@
"""Hosted Target Ledger append API (WP-0006-T04).
The highest-risk property this module owns: any party who independently
computes `fold.fold_outstanding_target` over an exported Manifest + Ledger
must get exactly the same Outstanding Target the hosted service itself
would report. This module therefore does nothing clever it authenticates
the write, validates the entry with the same offline `validation.py`
checks Stage 0 already tested, computes `previous_entry_hash` and
`signature` itself (never trusting client-supplied values for either), and
appends. The fold is never computed or stored here; `fold.py` remains the
single source of truth, run fresh from whatever a caller exports.
"""
from __future__ import annotations
from typing import Any
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from psycopg import Connection
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
from . import hashing, validation
from .registry import Licensor, RegistrationError, get_phase_manifest
def append_entry(
conn: Connection,
licensor: Licensor,
phase_id: str,
entry_input: dict[str, Any],
signing_key: Ed25519PrivateKey,
) -> dict[str, Any]:
"""Validate, chain, sign, and persist one Target Ledger entry.
`entry_input` must not include `previous_entry_hash` or `signature`
those are exclusively server-computed. Supplying them is rejected
outright rather than silently overwritten, so a caller never mistakes
a value it sent for one the service actually used.
"""
if "previous_entry_hash" in entry_input or "signature" in entry_input:
raise RegistrationError(
"previous_entry_hash and signature are computed by the Trust "
"Service instance and must not be supplied by the caller"
)
manifest = get_phase_manifest(conn, phase_id)
if manifest is None:
raise RegistrationError(f"phase {phase_id!r} is not registered")
manifest_licensor = conn.execute(
"SELECT licensor_id FROM phase_manifests WHERE phase_id = %s", (phase_id,)
).fetchone()[0]
if manifest_licensor != licensor.licensor_id:
raise RegistrationError(
"this token is not authorized to append to this Phase's ledger"
)
if entry_input.get("phase") != phase_id:
raise RegistrationError(
f"entry.phase {entry_input.get('phase')!r} does not match "
f"the target Phase {phase_id!r}"
)
# Serialize concurrent appends to this Phase so `previous_entry_hash`
# always reflects a real, uncontested chain tip — a transaction-scoped
# advisory lock, released automatically at commit/rollback.
conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (phase_id,))
tip = conn.execute(
"""
SELECT entry FROM ledger_entries
WHERE phase_id = %s
ORDER BY sequence DESC
LIMIT 1
""",
(phase_id,),
).fetchone()
previous_entry_hash = hashing.entry_hash(tip[0]) if tip else hashing.GENESIS
candidate = {**entry_input, "previous_entry_hash": previous_entry_hash}
try:
validation.validate_ledger_entry(candidate)
except validation.ConformanceError as exc:
raise RegistrationError(
"ledger entry rejected: " + "; ".join(exc.errors)
) from exc
currency_errors = validation.check_currency_consistency(manifest, [candidate])
if currency_errors:
raise RegistrationError(
"ledger entry rejected: " + "; ".join(currency_errors)
)
signed = {**candidate, "signature": hashing.sign_record(candidate, signing_key)}
try:
conn.execute(
"""
INSERT INTO ledger_entries
(entry_id, phase_id, entry, previous_entry_hash, signature, recognized_at)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
signed["id"],
phase_id,
Jsonb(signed),
previous_entry_hash,
signed["signature"],
signed["recognized_at"],
),
)
except UniqueViolation as exc:
raise RegistrationError(
f"entry id {signed['id']!r} already exists"
) from exc
return signed
def get_ledger(conn: Connection, phase_id: str) -> list[dict[str, Any]]:
"""Return a Phase's entries in exact append order (fold input order)."""
rows = conn.execute(
"""
SELECT entry FROM ledger_entries
WHERE phase_id = %s
ORDER BY sequence ASC
""",
(phase_id,),
).fetchall()
return [row[0] for row in rows]

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()