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:
parent
caa98ead3b
commit
28f0f429b2
7 changed files with 341 additions and 3 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
24
migrations/0003_attestations.sql
Normal file
24
migrations/0003_attestations.sql
Normal 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;
|
||||
112
src/target_revenue/attestation.py
Normal file
112
src/target_revenue/attestation.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
52
tests/test_attestation.py
Normal file
52
tests/test_attestation.py
Normal 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"]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue