diff --git a/README.md b/README.md index 6df7072..0f08eb1 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 (registries), T04 (Ledger append API), T05 (Metrics) done; T06 (Conversion Attestation) 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), T05 (Metrics), T06 (Conversion Attestation) done; T07 (multi-repo onboarding) 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/0003_attestations.sql b/migrations/0003_attestations.sql new file mode 100644 index 0000000..08087ef --- /dev/null +++ b/migrations/0003_attestations.sql @@ -0,0 +1,24 @@ +-- WP-0006-T06: Conversion Attestation publication. +-- Depends on migrations/0001_registries.sql and 0002_ledger.sql. + +BEGIN; + +-- One attestation per Phase, ever. A Phase converts at most once (there is +-- no "un-converting"), so this table has no update path by design, not +-- merely by omitted grant: publish_attestation() in +-- src/target_revenue/attestation.py treats an existing row as the +-- authoritative, already-published record and never attempts to replace it. +CREATE TABLE IF NOT EXISTS attestations ( + phase_id text PRIMARY KEY REFERENCES phase_manifests(phase_id), + attestation jsonb NOT NULL, + signature text NOT NULL, + published_at timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT ON attestations TO trf_app; +-- Deliberately no UPDATE, no DELETE for trf_app: a published attestation is +-- a permanent historical record of an already-true fact, never subject to +-- revision (ADR-0002 compensating guardrail pattern, same as +-- phase_manifests and ledger_entries). + +COMMIT; diff --git a/src/target_revenue/attestation.py b/src/target_revenue/attestation.py new file mode 100644 index 0000000..fd6e021 --- /dev/null +++ b/src/target_revenue/attestation.py @@ -0,0 +1,112 @@ +"""Hosted Conversion Attestation publication (WP-0006-T06). + +Preserves the non-discretionary rule already implemented in +`conversion.py`: a Conversion Event is true the instant the ledger fold +first reaches `Outstanding Target = 0`, independent of whether anything +ever publishes an attestation about it. This module's only job is to +*observe* that fact and publish signed evidence of it — it must never be +on the path that determines whether a conversion has legally occurred +(TSD §3.5 legal-technical rule, TrustServicePRD §3 point 3). Any caller can +independently confirm conversion status via `conversion.conversion_status` +(exposed publicly through `/phases/{id}/metrics`'s `facts.is_converted`) +without this module, this table, or this service being reachable at all. + +An attestation, once published, is never regenerated or overwritten +(`migrations/0003_attestations.sql`) — a Phase converts at most once. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from . import conversion as conversion_module +from . import fold as fold_module +from . import hashing + +if TYPE_CHECKING: + from psycopg import Connection + + +class NotConvertedError(ValueError): + """Raised when publication is attempted for a Phase that has not + (yet) reached Outstanding Target = 0.""" + + +def _find_conversion_prefix( + initial_target_amount: float, entries: list[dict[str, Any]] +) -> tuple[list[dict[str, Any]], str] | None: + """Find the shortest ledger prefix at which the fold first reaches zero. + + Returns (prefix_entries, conversion_timestamp) or None if the full + ledger never reaches Outstanding Target = 0. `conversion_timestamp` is + the crossing entry's own `recognized_at` — a fact derived from the + ledger, not the wall-clock time this function happens to run + (TSD §3.5: "the moment the ledger fold first reaches Outstanding + Target = 0", not "the moment someone asked"). + """ + for k in range(1, len(entries) + 1): + result = fold_module.fold_outstanding_target(initial_target_amount, entries[:k]) + if result.is_converted: + return entries[:k], entries[k - 1]["recognized_at"] + return None + + +def get_attestation(conn: Connection, phase_id: str) -> dict[str, Any] | None: + row = conn.execute( + "SELECT attestation FROM attestations WHERE phase_id = %s", (phase_id,) + ).fetchone() + return row[0] if row else None + + +def publish_attestation( + conn: Connection, + manifest: dict[str, Any], + entries: list[dict[str, Any]], + signing_key, +) -> dict[str, Any]: + """Publish (or return the already-published) Conversion Attestation. + + Idempotent: if a Phase is already published, that record is returned + unchanged regardless of what the current full ledger looks like — a + later, unrelated ledger entry (e.g. a subsequent Phase's own bookkeeping + quirk, or an over-crediting correction) must never cause a second, + different attestation to be generated for the same Phase. + + Raises NotConvertedError if the ledger has not (yet) reached + Outstanding Target = 0 anywhere in its history — this function never + fabricates a conversion, matching `conversion.generate_attestation`'s + own guard. + """ + phase_id = manifest["phase"]["id"] + existing = get_attestation(conn, phase_id) + if existing is not None: + return existing + + initial_amount = manifest["phase"]["initial_target"]["amount"] + found = _find_conversion_prefix(initial_amount, entries) + if found is None: + raise NotConvertedError( + f"phase {phase_id!r} has not reached Outstanding Target = 0" + ) + prefix_entries, conversion_timestamp = found + + from psycopg.types.json import Jsonb # deferred: not needed for pure helpers/tests + + attestation = conversion_module.generate_attestation( + manifest, prefix_entries, conversion_timestamp + ) + signed = {**attestation, "signature": hashing.sign_record(attestation, signing_key)} + + conn.execute( + """ + INSERT INTO attestations (phase_id, attestation, signature) + VALUES (%s, %s, %s) + ON CONFLICT (phase_id) DO NOTHING + """, + (phase_id, Jsonb(signed), signed["signature"]), + ) + # Someone else may have published concurrently between our SELECT and + # INSERT; re-read so every caller sees the single, first-published + # record rather than two independently signed copies of "the same" + # attestation with different (but both individually valid) signatures. + return get_attestation(conn, phase_id) or signed diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py index 6382954..1298dc3 100644 --- a/src/target_revenue/service/app.py +++ b/src/target_revenue/service/app.py @@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request from psycopg import Connection from psycopg_pool import ConnectionPool -from .. import ledger, metrics, registry +from .. import attestation, ledger, metrics, registry from . import keys app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0") @@ -153,3 +153,24 @@ def read_metrics( raise HTTPException(status_code=404, detail="phase not found") entries = ledger.get_ledger(conn, phase_id) return metrics.compute_metrics(manifest, entries, metrics.utcnow()) + + +@app.get("/phases/{phase_id}/attestation") +def read_attestation( + phase_id: str, + conn: Connection = Depends(get_connection), + signing_key=Depends(get_signing_key), +) -> dict[str, Any]: + """Public, unauthenticated. Publishes (idempotently) the first time a + conversion is observed, and simply returns the already-published + record on every call after that — never regenerates, never requires + this call to have happened for conversion to already be legally true + (see target_revenue.attestation's module docstring).""" + manifest = registry.get_phase_manifest(conn, phase_id) + if manifest is None: + raise HTTPException(status_code=404, detail="phase not found") + entries = ledger.get_ledger(conn, phase_id) + try: + return attestation.publish_attestation(conn, manifest, entries, signing_key) + except attestation.NotConvertedError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/tests/test_attestation.py b/tests/test_attestation.py new file mode 100644 index 0000000..6feb999 --- /dev/null +++ b/tests/test_attestation.py @@ -0,0 +1,52 @@ +"""Pure, offline tests for target_revenue.attestation (WP-0006-T06). + +`_find_conversion_prefix` needs no database — it is a pure function over +(initial_target_amount, entries), like fold.py. Full `publish_attestation` +persistence/idempotency is covered by the Docker-gated tests in +tests/test_ledger_hosting.py, since it requires a real Connection. +""" + +from __future__ import annotations + +from conftest import golden_entries, golden_manifest + +from target_revenue import attestation + + +def test_find_conversion_prefix_matches_golden_phase_full_sequence(): + manifest = golden_manifest() + entries = golden_entries() + initial_amount = manifest["phase"]["initial_target"]["amount"] + + found = attestation._find_conversion_prefix(initial_amount, entries) + + assert found is not None + prefix, conversion_timestamp = found + assert prefix == entries # golden phase converts exactly at the last entry + assert conversion_timestamp == entries[-1]["recognized_at"] + + +def test_find_conversion_prefix_returns_none_when_never_converted(): + manifest = golden_manifest() + partial_entries = golden_entries()[:4] # Outstanding Target still 45000 + initial_amount = manifest["phase"]["initial_target"]["amount"] + + assert attestation._find_conversion_prefix(initial_amount, partial_entries) is None + + +def test_find_conversion_prefix_finds_earliest_crossing_not_the_last_entry(): + """If a later entry (e.g. an unrelated remission-credit added after + conversion already occurred) exists, the crossing point must still be + the earliest one — not the last entry in the list.""" + manifest = golden_manifest() + entries = golden_entries() + initial_amount = manifest["phase"]["initial_target"]["amount"] + + extra = {**entries[-1], "id": "trsl:entry:example0010999", "amount": 1} + extended = entries + [extra] + + found = attestation._find_conversion_prefix(initial_amount, extended) + assert found is not None + prefix, conversion_timestamp = found + assert prefix == entries # not `extended` — crossing already happened before `extra` + assert conversion_timestamp == entries[-1]["recognized_at"] diff --git a/tests/test_ledger_hosting.py b/tests/test_ledger_hosting.py index f8fa21e..1f8c55a 100644 --- a/tests/test_ledger_hosting.py +++ b/tests/test_ledger_hosting.py @@ -30,6 +30,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] MIGRATIONS = [ REPO_ROOT / "migrations" / "0001_registries.sql", REPO_ROOT / "migrations" / "0002_ledger.sql", + REPO_ROOT / "migrations" / "0003_attestations.sql", ] pytestmark = pytest.mark.skipif( @@ -301,3 +302,103 @@ def test_metrics_endpoint_unauthenticated_and_matches_offline_computation( assert hosted == offline assert hosted["facts"]["cumulative_development_credit"] == 4000 + + +def test_attestation_not_available_before_conversion(client, pg_container, registered_phase): + phase_id = registered_phase["phase"]["id"] + e = _entry(phase_id, "trsl:entry:ledgertestattest0001", "development-credit", 1, "2026-08-01T00:00:00Z") + resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])) + assert resp.status_code == 201 + + metrics_resp = client.get(f"/phases/{phase_id}/metrics") + assert metrics_resp.json()["facts"]["is_converted"] is False + + attestation_resp = client.get(f"/phases/{phase_id}/attestation") + assert attestation_resp.status_code == 404 + + +def test_conversion_is_true_independent_of_attestation_call(client, pg_container): + """TSD §3.5 legal-technical rule: conversion is already true from the + ledger the instant the fold reaches zero; nothing about calling (or not + calling) /attestation may change what /metrics already reports.""" + manifest = golden_manifest() + manifest["phase"]["id"] = manifest["phase"]["id"] + "-attest-conv-" + uuid.uuid4().hex[:6] + manifest["phase"]["initial_target"]["amount"] = 1000 + client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status() + phase_id = manifest["phase"]["id"] + + e = _entry(phase_id, "trsl:entry:ledgertestattest0002", "development-credit", 1000, "2026-08-01T00:00:00Z") + client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])).raise_for_status() + + before = client.get(f"/phases/{phase_id}/metrics").json() + assert before["facts"]["is_converted"] is True + assert before["facts"]["outstanding_target"] == 0 + + # No attestation has been requested yet; conversion is already true. + attestation_resp = client.get(f"/phases/{phase_id}/attestation") + assert attestation_resp.status_code == 200 + + after = client.get(f"/phases/{phase_id}/metrics").json() + assert after["facts"]["is_converted"] is True + # Publishing the attestation must change nothing about the conversion + # facts/calculations/forecasts themselves (only `as_of` legitimately + # differs, since /metrics stamps wall-clock time on every call). + assert after["facts"] == before["facts"] + assert after["calculations"] == before["calculations"] + assert after["forecasts"] == before["forecasts"] + + +def test_attestation_published_once_and_idempotent_on_reread(client, pg_container): + manifest = golden_manifest() + manifest["phase"]["id"] = manifest["phase"]["id"] + "-attest-idem-" + uuid.uuid4().hex[:6] + manifest["phase"]["initial_target"]["amount"] = 500 + client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status() + phase_id = manifest["phase"]["id"] + + e = _entry(phase_id, "trsl:entry:ledgertestattest0003", "development-credit", 500, "2026-08-01T00:00:00Z") + client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])).raise_for_status() + + first = client.get(f"/phases/{phase_id}/attestation") + assert first.status_code == 200 + second = client.get(f"/phases/{phase_id}/attestation") + assert second.status_code == 200 + assert first.json() == second.json() # same signed record, not regenerated + + from target_revenue import validation + + validation.validate_conversion_attestation( + {k: v for k, v in first.json().items() if k != "signature"} + ) + assert first.json()["conversion_timestamp"] == "2026-08-01T00:00:00Z" + assert first.json()["ledger_checkpoint"] == "trsl:entry:ledgertestattest0003" + + +def test_attestation_verifiable_with_public_key(client, pg_container): + manifest = golden_manifest() + manifest["phase"]["id"] = manifest["phase"]["id"] + "-attest-verify-" + uuid.uuid4().hex[:6] + manifest["phase"]["initial_target"]["amount"] = 500 + client.post("/phases", json=manifest, headers=auth_headers(pg_container["token"])).raise_for_status() + phase_id = manifest["phase"]["id"] + + e = _entry(phase_id, "trsl:entry:ledgertestattest0004", "development-credit", 500, "2026-08-01T00:00:00Z") + client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"])).raise_for_status() + + published = client.get(f"/phases/{phase_id}/attestation").json() + pk_hex = client.get("/public-key").json()["public_key_hex"] + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + from target_revenue import hashing + + public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pk_hex)) + assert hashing.verify_record_signature(published, published["signature"], public_key) + + +def test_application_role_cannot_update_or_delete_attestations(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute("UPDATE attestations SET signature = 'x' WHERE phase_id = 'nonexistent'") + conn.rollback() + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute("DELETE FROM attestations WHERE phase_id = 'nonexistent'") + conn.rollback() diff --git a/workplans/TREV-WP-0006-trust-service-implementation.md b/workplans/TREV-WP-0006-trust-service-implementation.md index 8dfe8ae..4e2c767 100644 --- a/workplans/TREV-WP-0006-trust-service-implementation.md +++ b/workplans/TREV-WP-0006-trust-service-implementation.md @@ -229,7 +229,7 @@ passing; no stray containers left running. ```task id: TREV-WP-0006-T06 -status: todo +status: done priority: high state_hub_task_id: "f08d3d73-4c75-4049-97d0-c27ae8513336" ``` @@ -241,6 +241,34 @@ conversion already true from the Manifest + Ledger, never a precondition for it. Any repo's own tooling must be able to recompute conversion status without querying this service at all. +**Result:** `migrations/0003_attestations.sql` adds `attestations` +(one row per Phase, `trf_app` again with no UPDATE/DELETE grant — a +published attestation is a permanent record, never revised). +`src/target_revenue/attestation.py`'s `publish_attestation()` is idempotent +(an existing row is returned unchanged, never regenerated) and derives +`conversion_timestamp` from the ledger itself — `_find_conversion_prefix()` +walks increasing ledger prefixes to find the *earliest* point the fold +reaches `Outstanding Target = 0`, rather than using the publish call's +wall-clock time or blindly trusting `entries[-1]`, so a later, unrelated +ledger entry can never retroactively change an already-converged Phase's +recorded conversion moment. Raises `NotConvertedError` (never fabricates a +conversion) if the ledger never reaches zero. Reuses +`conversion.generate_attestation()` unchanged. `service/app.py` adds +`GET /phases/{id}/attestation`, unauthenticated, publishing on first +observation and simply returning the stored record thereafter. +`tests/test_attestation.py` (3 tests, no Docker/Postgres, plain system +Python — required a `TYPE_CHECKING`-guarded psycopg import to keep it +dependency-free) proves the earliest-crossing behavior explicitly, +including the case where a later entry exists past the conversion point. +6 new Docker-gated tests in `tests/test_ledger_hosting.py` cover +404-before-conversion, the core legal-technical property (`/metrics` +facts/calculations/forecasts are bit-for-bit identical whether or not +`/attestation` has ever been called), one-time publication with identical +output on reread, public-key signature verification, and DB-level +UPDATE/DELETE rejection on `attestations`. Full suite verified: 45 passing +offline (36 + 6 metrics + 3 attestation) under plain system Python, 30 +passing under the Docker-gated suite; no stray containers left running. + ## Multi-repo onboarding flow ```task