diff --git a/README.md b/README.md index f8bbeac..fb04a75 100644 --- a/README.md +++ b/README.md @@ -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-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-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-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 | diff --git a/migrations/0002_ledger.sql b/migrations/0002_ledger.sql new file mode 100644 index 0000000..4607aac --- /dev/null +++ b/migrations/0002_ledger.sql @@ -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; diff --git a/src/target_revenue/ledger.py b/src/target_revenue/ledger.py new file mode 100644 index 0000000..77c6e39 --- /dev/null +++ b/src/target_revenue/ledger.py @@ -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] diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py index 337f043..7461575 100644 --- a/src/target_revenue/service/app.py +++ b/src/target_revenue/service/app.py @@ -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) diff --git a/src/target_revenue/service/keys.py b/src/target_revenue/service/keys.py new file mode 100644 index 0000000..3aff03c --- /dev/null +++ b/src/target_revenue/service/keys.py @@ -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() diff --git a/tests/test_ledger_hosting.py b/tests/test_ledger_hosting.py new file mode 100644 index 0000000..c487899 --- /dev/null +++ b/tests/test_ledger_hosting.py @@ -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"] diff --git a/workplans/TREV-WP-0006-trust-service-implementation.md b/workplans/TREV-WP-0006-trust-service-implementation.md index e956ec8..1d2534c 100644 --- a/workplans/TREV-WP-0006-trust-service-implementation.md +++ b/workplans/TREV-WP-0006-trust-service-implementation.md @@ -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