Implement hosted Conversion Attestation publication (WP-0006-T06)

migrations/0003_attestations.sql: one row per Phase, no UPDATE/DELETE
grant for trf_app (a published attestation is permanent).

src/target_revenue/attestation.py: publish_attestation() is idempotent
(existing row returned unchanged, never regenerated) and derives
conversion_timestamp from the ledger itself via _find_conversion_prefix(),
which finds the earliest prefix where the fold reaches Outstanding
Target = 0 rather than trusting entries[-1] or wall-clock publish time -
so a later unrelated entry can never change an already-converged Phase's
recorded conversion moment. Raises NotConvertedError rather than
fabricating a conversion. Reuses conversion.generate_attestation()
unchanged. Guarded the psycopg import behind TYPE_CHECKING so the pure
helper stays testable under plain system Python.

service/app.py adds GET /phases/{id}/attestation (unauthenticated,
publish-on-first-observation).

tests/test_attestation.py (3 tests, no Docker/Postgres) proves the
earliest-crossing behavior. 6 new Docker-gated tests in
test_ledger_hosting.py cover pre-conversion 404, the core
legal-technical property that /metrics facts/calculations/forecasts
are identical whether or not /attestation was ever called, one-time
publication, signature verification, and DB-level UPDATE/DELETE
rejection.
This commit is contained in:
tegwick 2026-07-29 21:50:02 +02:00
parent caa98ead3b
commit 28f0f429b2
7 changed files with 341 additions and 3 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 (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-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,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;

View file

@ -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

View file

@ -18,7 +18,7 @@ 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 ledger, metrics, registry from .. import attestation, ledger, metrics, registry
from . import keys 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")
@ -153,3 +153,24 @@ def read_metrics(
raise HTTPException(status_code=404, detail="phase not found") raise HTTPException(status_code=404, detail="phase not found")
entries = ledger.get_ledger(conn, phase_id) entries = ledger.get_ledger(conn, phase_id)
return metrics.compute_metrics(manifest, entries, metrics.utcnow()) 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

52
tests/test_attestation.py Normal file
View file

@ -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"]

View file

@ -30,6 +30,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = [ MIGRATIONS = [
REPO_ROOT / "migrations" / "0001_registries.sql", REPO_ROOT / "migrations" / "0001_registries.sql",
REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0002_ledger.sql",
REPO_ROOT / "migrations" / "0003_attestations.sql",
] ]
pytestmark = pytest.mark.skipif( pytestmark = pytest.mark.skipif(
@ -301,3 +302,103 @@ def test_metrics_endpoint_unauthenticated_and_matches_offline_computation(
assert hosted == offline assert hosted == offline
assert hosted["facts"]["cumulative_development_credit"] == 4000 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()

View file

@ -229,7 +229,7 @@ passing; no stray containers left running.
```task ```task
id: TREV-WP-0006-T06 id: TREV-WP-0006-T06
status: todo status: done
priority: high priority: high
state_hub_task_id: "f08d3d73-4c75-4049-97d0-c27ae8513336" 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 for it. Any repo's own tooling must be able to recompute conversion status
without querying this service at all. 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 ## Multi-repo onboarding flow
```task ```task