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

@ -78,7 +78,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule**
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 | | [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) | | [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) | | [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (Phase/Extension Registry hosting) done; T04 (Ledger append API) next | | [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (registries), T04 (Ledger append API) done; T05 (Metrics) next |
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started | | [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 | | [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |

View file

@ -0,0 +1,33 @@
-- WP-0006-T04: hosted Target Ledger append API.
-- Depends on migrations/0001_registries.sql (phase_manifests, licensors).
BEGIN;
CREATE TABLE IF NOT EXISTS ledger_entries (
sequence bigint GENERATED ALWAYS AS IDENTITY,
entry_id text NOT NULL,
phase_id text NOT NULL REFERENCES phase_manifests(phase_id),
entry jsonb NOT NULL,
previous_entry_hash text NOT NULL,
signature text NOT NULL,
recognized_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (phase_id, sequence),
UNIQUE (entry_id)
);
-- Ordering within a Phase must be exactly append order for the fold and
-- hash chain to mean anything; index supports the ORDER BY sequence read
-- path (registry/ledger.py get_ledger()).
CREATE INDEX IF NOT EXISTS ledger_entries_phase_sequence_idx
ON ledger_entries (phase_id, sequence);
GRANT SELECT, INSERT ON ledger_entries TO trf_app;
-- Deliberately no UPDATE, no DELETE, and no direct control over `sequence`
-- (GENERATED ALWAYS AS IDENTITY — trf_app cannot even attempt to set it) for
-- trf_app: append-only is a database fact here, matching phase_manifests
-- and extensions in migrations/0001_registries.sql, not merely an API
-- design intention (ADR-0002 compensating guardrail 2).
GRANT USAGE, SELECT ON ledger_entries_sequence_seq TO trf_app;
COMMIT;

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 """FastAPI surface for the hosted Phase Registry, Extension Registry
(WP-0006-T03). Only registration and read endpoints live here — the Target (WP-0006-T03), and Target Ledger append API (WP-0006-T04). Metrics (T05)
Ledger append API (T04), Metrics (T05), and Attestation (T06) are separate and Conversion Attestation (T06) are separate components per
components per `specs/TechnicalSpecificationDocument.md` §4.1 and are not `specs/TechnicalSpecificationDocument.md` §4.1 and are not implemented in
implemented in this module. this module.
Every route delegates to `target_revenue.registry`; this file's only job is Every route delegates to `target_revenue.registry` / `target_revenue.ledger`;
HTTP framing (status codes, request/response shape) and reading the bearer this file's only job is HTTP framing (status codes, request/response shape,
token, not conformance logic. bearer-token extraction), not conformance or chaining logic.
""" """
from __future__ import annotations from __future__ import annotations
@ -18,7 +18,8 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from psycopg import Connection from psycopg import Connection
from psycopg_pool import ConnectionPool 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") app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
@ -34,6 +35,12 @@ def get_pool() -> ConnectionPool:
return app.state.pool 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(): def get_connection():
pool = get_pool() pool = get_pool()
with pool.connection() as conn: with pool.connection() as conn:
@ -64,7 +71,7 @@ def register_phase(
return {"phase_id": manifest["phase"]["id"], "status": "registered"} 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]: def read_phase(phase_id: str, conn: Connection = Depends(get_connection)) -> dict[str, Any]:
manifest = registry.get_phase_manifest(conn, phase_id) manifest = registry.get_phase_manifest(conn, phase_id)
if manifest is None: if manifest is None:
@ -97,3 +104,39 @@ def read_extension(
if result is None: if result is None:
raise HTTPException(status_code=404, detail="extension not found") raise HTTPException(status_code=404, detail="extension not found")
return result 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()

View file

@ -0,0 +1,276 @@
"""Integration tests for WP-0006-T04 (hosted Target Ledger append API).
Same ephemeral, disposable Postgres-via-Docker pattern as
test_registry_hosting.py (never the shared state-hub instance). The single
property this file tests most aggressively, per the task's own framing:
appending entries through the hosted API and then folding the exported
result offline (`fold.fold_outstanding_target`) must produce exactly the
same Outstanding Target a hosted metrics/attestation component would later
report — a hosted service that silently diverges from the offline fold is
the highest-risk defect this component could introduce.
"""
from __future__ import annotations
import shutil
import subprocess
import time
import uuid
from pathlib import Path
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from conftest import golden_manifest # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = [
REPO_ROOT / "migrations" / "0001_registries.sql",
REPO_ROOT / "migrations" / "0002_ledger.sql",
]
pytestmark = pytest.mark.skipif(
shutil.which("docker") is None, reason="docker not available"
)
@pytest.fixture(scope="module")
def pg_container():
name = f"trf-test-pg-ledger-{uuid.uuid4().hex[:8]}"
subprocess.run(
[
"docker", "run", "--rm", "-d",
"--name", name,
"-e", "POSTGRES_PASSWORD=postgres",
"-e", "POSTGRES_DB=target_revenue_test",
"-p", "127.0.0.1::5432",
"postgres:16-alpine",
],
check=True, capture_output=True,
)
try:
port_out = subprocess.run(
["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True
).stdout.strip()
host_port = port_out.split(":")[-1]
dsn = f"host=127.0.0.1 port={host_port} dbname=target_revenue_test user=postgres password=postgres"
for _ in range(60):
try:
with psycopg.connect(dsn, connect_timeout=1):
break
except psycopg.OperationalError:
time.sleep(0.5)
else:
raise RuntimeError("postgres container did not become ready in time")
with psycopg.connect(dsn) as conn:
for migration in MIGRATIONS:
conn.execute(migration.read_text(encoding="utf-8"))
conn.commit()
token = "test-token-acme"
conn.execute(
"INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)",
(token, "acme-corp"),
)
other_token = "test-token-other"
conn.execute(
"INSERT INTO licensors (token, licensor_id) VALUES (%s, %s)",
(other_token, "other-corp"),
)
conn.commit()
app_dsn = (
f"host=127.0.0.1 port={host_port} dbname=target_revenue_test "
f"user=trf_app password=changeme-in-deployment"
)
yield {
"admin_dsn": dsn,
"app_dsn": app_dsn,
"token": token,
"other_token": other_token,
}
finally:
subprocess.run(["docker", "stop", name], capture_output=True)
@pytest.fixture()
def client(pg_container, monkeypatch):
monkeypatch.setenv("TRF_DATABASE_URL", pg_container["app_dsn"])
monkeypatch.setenv("TRF_SIGNING_KEY_HEX", "11" * 32)
from target_revenue.service import app as app_module
if hasattr(app_module.app.state, "pool"):
app_module.app.state.pool.close()
del app_module.app.state.pool
if hasattr(app_module.app.state, "signing_key"):
del app_module.app.state.signing_key
with TestClient(app_module.app) as c:
yield c
def auth_headers(token: str):
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def registered_phase(client, pg_container):
manifest = golden_manifest()
manifest["phase"]["id"] = manifest["phase"]["id"] + "-ledger-test-" + uuid.uuid4().hex[:6]
resp = client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 201, resp.text
return manifest
def _entry(phase_id: str, entry_id: str, entry_type: str, amount: float, recognized_at: str):
return {
"id": entry_id,
"phase": phase_id,
"type": entry_type,
"amount": amount,
"currency": "USD",
"recognized_at": recognized_at,
"evidence_reference": f"confidential:evidence:{entry_id}",
"extension": {"id": "trsl:extension:development-license", "version": "1.0"},
}
def test_append_and_hash_chain_links(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e1 = _entry(phase_id, "trsl:entry:ledgertest0001", "development-credit", 1000, "2026-08-01T00:00:00Z")
r1 = client.post(f"/phases/{phase_id}/ledger", json=e1, headers=auth_headers(pg_container["token"]))
assert r1.status_code == 201, r1.text
stored1 = r1.json()
assert stored1["previous_entry_hash"] == "GENESIS"
assert "signature" in stored1
e2 = _entry(phase_id, "trsl:entry:ledgertest0002", "development-credit", 500, "2026-08-02T00:00:00Z")
r2 = client.post(f"/phases/{phase_id}/ledger", json=e2, headers=auth_headers(pg_container["token"]))
assert r2.status_code == 201, r2.text
stored2 = r2.json()
from target_revenue import hashing
assert stored2["previous_entry_hash"] == hashing.entry_hash(stored1)
# Full chain, as exported, must independently verify.
hashing.verify_chain([stored1, stored2])
def test_client_cannot_supply_previous_entry_hash_or_signature(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
forged = _entry(phase_id, "trsl:entry:ledgertest0003", "development-credit", 1, "2026-08-01T00:00:00Z")
forged["previous_entry_hash"] = "GENESIS"
resp = client.post(f"/phases/{phase_id}/ledger", json=forged, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 422
assert "must not be supplied" in resp.json()["detail"]
def test_other_licensor_cannot_append_to_this_phase(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertest0004", "development-credit", 1, "2026-08-01T00:00:00Z")
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["other_token"]))
assert resp.status_code == 422
assert "not authorized" in resp.json()["detail"]
def test_currency_mismatch_rejected(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertest0005", "development-credit", 1, "2026-08-01T00:00:00Z")
e["currency"] = "EUR"
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 422
assert "currency" in resp.json()["detail"]
def test_hosted_ledger_export_folds_identically_to_offline_computation(
client, pg_container, registered_phase
):
"""The property T04's task description calls out as highest-risk:
hosted append + export, folded offline, must match what was appended."""
from target_revenue import fold, hashing
phase_id = registered_phase["phase"]["id"]
plan = [
("development-credit", 25000),
("development-credit", 10000),
("remission-credit", 5000),
("credit-reversal", 2000),
]
for i, (entry_type, amount) in enumerate(plan, start=1):
e = _entry(
phase_id,
f"trsl:entry:ledgertestfold{i:04d}",
entry_type,
amount,
f"2026-08-0{i}T00:00:00Z",
)
if entry_type == "credit-reversal":
e["reverses"] = "trsl:entry:ledgertestfold0001"
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert resp.status_code == 201, resp.text
export = client.get(f"/phases/{phase_id}/ledger")
assert export.status_code == 200
entries = export.json()
assert len(entries) == len(plan)
# Chain integrity survives the round trip through Postgres/JSONB/API.
hashing.verify_chain(entries)
result = fold.fold_outstanding_target(
registered_phase["phase"]["initial_target"]["amount"], entries
)
expected_development_credit = 25000 + 10000 - 2000
expected_remission_credit = 5000
assert result.development_credit == expected_development_credit
assert result.remission_credit == expected_remission_credit
assert result.outstanding_target == max(
0.0,
registered_phase["phase"]["initial_target"]["amount"]
- expected_development_credit
- expected_remission_credit,
)
def test_public_key_endpoint_unauthenticated_and_verifies_signature(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestpk0001", "development-credit", 1, "2026-08-01T00:00:00Z")
stored = client.post(
f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])
).json()
pk_resp = client.get("/public-key")
assert pk_resp.status_code == 200
assert pk_resp.json()["algorithm"] == "ed25519"
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from target_revenue import hashing
public_key = Ed25519PublicKey.from_public_bytes(
bytes.fromhex(pk_resp.json()["public_key_hex"])
)
assert hashing.verify_record_signature(stored, stored["signature"], public_key)
def test_application_role_cannot_update_or_delete_ledger_entries(pg_container):
with psycopg.connect(pg_container["app_dsn"]) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE ledger_entries SET signature = 'x' WHERE entry_id = 'nonexistent'")
conn.rollback()
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM ledger_entries WHERE entry_id = 'nonexistent'")
conn.rollback()
def test_duplicate_entry_id_rejected(client, pg_container, registered_phase):
phase_id = registered_phase["phase"]["id"]
e = _entry(phase_id, "trsl:entry:ledgertestdup0001", "development-credit", 1, "2026-08-01T00:00:00Z")
first = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert first.status_code == 201
second = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
assert second.status_code == 422
assert "already exists" in second.json()["detail"]

View file

@ -147,7 +147,7 @@ system Python (no service deps required).
```task ```task
id: TREV-WP-0006-T04 id: TREV-WP-0006-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "591e87d6-3b64-4f1f-b870-edf834edc522" 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 is the property to test most aggressively, since it's the one a hosted
service could most easily and least visibly break. 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 ## Metrics service
```task ```task