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

@ -147,7 +147,7 @@ system Python (no service deps required).
```task
id: TREV-WP-0006-T04
status: todo
status: done
priority: high
state_hub_task_id: "591e87d6-3b64-4f1f-b870-edf834edc522"
```
@ -159,6 +159,35 @@ export must get the same Outstanding Target as the hosted service — this
is the property to test most aggressively, since it's the one a hosted
service could most easily and least visibly break.
**Result:** `migrations/0002_ledger.sql` adds `ledger_entries`
(`GENERATED ALWAYS AS IDENTITY` sequence for exact append order; `trf_app`
again has no UPDATE/DELETE grant). `src/target_revenue/ledger.py`'s
`append_entry()`: rejects any caller-supplied `previous_entry_hash`/
`signature` outright (server-computed only), enforces phase ownership
(a Licensor may only append to its own registered Phase — TS-FR-8
isolation), serializes concurrent appends per Phase via
`pg_advisory_xact_lock`, computes the chain tip from the last stored entry
and signs with the Trust Service instance's own Ed25519 key
(`service/keys.py`, env-configured or ephemeral-with-warning for dev),
reusing `validation.py`'s existing schema/currency checks unchanged.
`service/app.py` adds `POST/GET /phases/{id}/ledger` and an unauthenticated
`GET /public-key` (so an external party can verify signatures without
trusting this API's own access control at all). Fixed an unrelated route
ordering bug found while wiring this in: `{phase_id:path}` on the plain
`GET /phases/{id}` route was greedily matching `/ledger`-suffixed paths
too, since phase IDs contain colons but no slashes and never needed the
`:path` converter — switched all phase routes to plain `{phase_id}`.
`tests/test_ledger_hosting.py` (8 tests, Docker-gated): hash-chain linkage
across appends, forged-hash/signature rejection, cross-Licensor isolation,
currency-mismatch rejection, duplicate-entry-id rejection, DB-level
UPDATE/DELETE privilege checks, signature verification via the public-key
endpoint, and — the task's own highest-priority property — appending a
mixed development-credit/remission-credit/credit-reversal sequence through
the API, exporting it, and confirming `fold.fold_outstanding_target` over
the export reproduces the exact expected Development Credit, Remission
Credit, and Outstanding Target. Offline 36-test suite re-verified unchanged
with plain system Python; no stray Docker containers left running.
## Metrics service
```task