diff --git a/README.md b/README.md index f2f1751..eab8845 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–T08 done (Postgres-backed registries/ledger/metrics/attestation, ADR-0002 accepted, onboarding CLI, hosted conformance suite); T09 (Breach/Compliance Record hosting, License V1C1 §7.4) added 2026-07-29, not yet started | +| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — **finished**, all 9 tasks done (Postgres-backed registries/ledger/metrics/attestation/breach-record, ADR-0002 accepted, onboarding CLI, hosted conformance suite) | | [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/0004_breach_records.sql b/migrations/0004_breach_records.sql new file mode 100644 index 0000000..ab7bb5c --- /dev/null +++ b/migrations/0004_breach_records.sql @@ -0,0 +1,48 @@ +-- WP-0006-T09: Breach/Compliance Record hosting. +-- Depends on migrations/0001_registries.sql. +-- +-- Per License V1C1 §7.4: a breach record's lifecycle is a sequence of +-- distinct, dated events (alleged -> cured, or alleged -> determined -> +-- terminated), never an edit of a prior event. "The Trust Service shall +-- update the record promptly upon resolution" (§7.4) means append a new +-- event for the same case, not mutate the existing one — the same +-- append-only pattern as phase_manifests/ledger_entries/attestations. + +BEGIN; + +CREATE TABLE IF NOT EXISTS breach_records ( + sequence bigint GENERATED ALWAYS AS IDENTITY, + record_id text NOT NULL UNIQUE, + case_id text NOT NULL, + phase_id text NOT NULL REFERENCES phase_manifests(phase_id), + event_type text NOT NULL + CHECK (event_type IN ('alleged', 'cured', 'determined', 'terminated')), + category text NOT NULL, + event_at timestamptz NOT NULL, + anonymized boolean NOT NULL DEFAULT true, + named_entitlement_holder text, + evidence_reference text, + published_by text NOT NULL REFERENCES licensors(licensor_id), + signature text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (phase_id, sequence), + -- License V1C1 §7.4: naming is governed exclusively by the applicable + -- Commercial Use Agreement's opt-in; a name may only be stored + -- alongside an explicit non-anonymized record, never as a side effect + -- of an anonymized one. + CHECK (anonymized OR named_entitlement_holder IS NOT NULL), + CHECK (NOT anonymized OR named_entitlement_holder IS NULL) +); + +CREATE INDEX IF NOT EXISTS breach_records_phase_sequence_idx + ON breach_records (phase_id, sequence); +CREATE INDEX IF NOT EXISTS breach_records_case_idx + ON breach_records (case_id); + +GRANT SELECT, INSERT ON breach_records TO trf_app; +-- Deliberately no UPDATE, no DELETE for trf_app: matching phase_manifests, +-- ledger_entries, and attestations, a published breach event is +-- permanent — resolution is recorded as a new event, never an edit. +GRANT USAGE, SELECT ON breach_records_sequence_seq TO trf_app; + +COMMIT; diff --git a/src/target_revenue/breach_record.py b/src/target_revenue/breach_record.py new file mode 100644 index 0000000..c0759e5 --- /dev/null +++ b/src/target_revenue/breach_record.py @@ -0,0 +1,171 @@ +"""Hosted Breach/Compliance Record publication (WP-0006-T09). + +Publishes the Licensor's own breach and termination determinations for a +Phase, per `specs/TargetRevenueSourceLicense-V1C1.md` §7.4 and +`specs/TechnicalSpecificationDocument.md` §4.1. This module never decides +whether a breach occurred, was cured, or was terminated — it only records, +signs, and publishes the Licensor's assertion of one of those facts, +exactly like `attestation.py` records an already-true conversion rather +than deciding one. The distinction between `alleged` and `determined` +(and the further `cured`/`terminated` outcomes) is the Licensor's call, or +a Commercial Use Agreement's dispute process's call — never this module's. + +Naming: default anonymized (Phase and category only, no party identified). +Named disclosure requires the caller to affirmatively assert that the +applicable Commercial Use Agreement's naming/disclosure clause (License +§7.4, CUA V1C1 §9) authorizes it — this module records that assertion, it +does not, and cannot, verify the underlying CUA text itself. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from . import hashing + +if TYPE_CHECKING: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from psycopg import Connection + + from .registry import Licensor + +_EVENT_TYPES = frozenset({"alleged", "cured", "determined", "terminated"}) +_REQUIRED_FIELDS = ("id", "case_id", "category", "event_at") + + +def _validate(event_input: dict[str, Any]) -> list[str]: + errors = [] + for field in _REQUIRED_FIELDS: + if not event_input.get(field): + errors.append(f"{field}: required") + event_type = event_input.get("event_type") + if event_type not in _EVENT_TYPES: + errors.append( + f"event_type: must be one of {sorted(_EVENT_TYPES)}, got {event_type!r}" + ) + anonymized = event_input.get("anonymized", True) + named_holder = event_input.get("named_entitlement_holder") + authorized = event_input.get("named_disclosure_authorized_under_cua", False) + if not anonymized: + if not named_holder: + errors.append( + "named_entitlement_holder: required when anonymized is false" + ) + if not authorized: + errors.append( + "named_disclosure_authorized_under_cua: must be true to publish a " + "named record — the Trust Service records this assertion, it does " + "not verify the underlying Commercial Use Agreement text " + "(License V1C1 §7.4)" + ) + elif named_holder: + errors.append( + "named_entitlement_holder: must not be set when anonymized is true " + "(default)" + ) + return errors + + +def publish_breach_event( + conn: "Connection", + licensor: "Licensor", + phase_id: str, + event_input: dict[str, Any], + signing_key: "Ed25519PrivateKey", +) -> dict[str, Any]: + """Validate, sign, and append one breach/compliance lifecycle event. + + Never mutates a prior event for the same `case_id` — resolution + (cure, determination, termination) is always a new, separately dated + event, per License V1C1 §7.4's "update the record promptly upon + resolution," which this module implements as append, not edit + (`migrations/0004_breach_records.sql` grants trf_app no UPDATE/DELETE + on this table at all). + """ + from .registry import RegistrationError, get_phase_manifest + + 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 publish breach records for this Phase" + ) + + errors = _validate(event_input) + if errors: + raise RegistrationError("breach event rejected: " + "; ".join(errors)) + + anonymized = event_input.get("anonymized", True) + record = { + "id": event_input["id"], + "case_id": event_input["case_id"], + "phase": phase_id, + "event_type": event_input["event_type"], + "category": event_input["category"], + "event_at": event_input["event_at"], + "anonymized": anonymized, + "named_entitlement_holder": event_input.get("named_entitlement_holder") if not anonymized else None, + "evidence_reference": event_input.get("evidence_reference"), + } + signed = {**record, "signature": hashing.sign_record(record, signing_key)} + + from psycopg.errors import UniqueViolation + + try: + conn.execute( + """ + INSERT INTO breach_records + (record_id, case_id, phase_id, event_type, category, event_at, + anonymized, named_entitlement_holder, evidence_reference, + published_by, signature) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + signed["id"], + signed["case_id"], + phase_id, + signed["event_type"], + signed["category"], + signed["event_at"], + signed["anonymized"], + signed["named_entitlement_holder"], + signed["evidence_reference"], + licensor.licensor_id, + signed["signature"], + ), + ) + except UniqueViolation as exc: + raise RegistrationError(f"breach record id {signed['id']!r} already exists") from exc + + return signed + + +def get_breach_records(conn: "Connection", phase_id: str) -> list[dict[str, Any]]: + """Public read: the Phase's full breach/compliance event history. + + Anonymized events never carry `named_entitlement_holder` in the first + place (enforced at publish time and by the table's own CHECK + constraint); this function does not need to additionally strip it, but + the caller must never bypass this function to read the table directly + for a public-facing response. + """ + rows = conn.execute( + """ + SELECT record_id, case_id, event_type, category, event_at, anonymized, + named_entitlement_holder, evidence_reference, signature + FROM breach_records + WHERE phase_id = %s + ORDER BY sequence ASC + """, + (phase_id,), + ).fetchall() + columns = ( + "id", "case_id", "event_type", "category", "event_at", "anonymized", + "named_entitlement_holder", "evidence_reference", "signature", + ) + return [dict(zip(columns, row)) for row in rows] diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py index 1298dc3..d013b79 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 attestation, ledger, metrics, registry +from .. import attestation, breach_record, ledger, metrics, registry from . import keys app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0") @@ -174,3 +174,32 @@ def read_attestation( return attestation.publish_attestation(conn, manifest, entries, signing_key) except attestation.NotConvertedError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post("/phases/{phase_id}/breach-records", status_code=201) +def publish_breach_record( + phase_id: str, + event: dict[str, Any], + licensor: registry.Licensor = Depends(get_licensor), + conn: Connection = Depends(get_connection), + signing_key=Depends(get_signing_key), +) -> dict[str, Any]: + """Publishes the Licensor's own breach/compliance determination — this + endpoint never adjudicates whether a breach occurred (TSD §4.1 + Breach/Compliance Record row, License V1C1 §7.4).""" + try: + return breach_record.publish_breach_event(conn, licensor, phase_id, event, signing_key) + except registry.RegistrationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.get("/phases/{phase_id}/breach-records") +def read_breach_records( + phase_id: str, conn: Connection = Depends(get_connection) +) -> list[dict[str, Any]]: + """Public, unauthenticated: anonymized by default per License V1C1 + §7.4; a named record only appears if it was published with the CUA + §9 opt-in authorization asserted.""" + if registry.get_phase_manifest(conn, phase_id) is None: + raise HTTPException(status_code=404, detail="phase not found") + return breach_record.get_breach_records(conn, phase_id) diff --git a/tests/test_breach_record.py b/tests/test_breach_record.py new file mode 100644 index 0000000..56ac130 --- /dev/null +++ b/tests/test_breach_record.py @@ -0,0 +1,69 @@ +"""Pure, offline tests for target_revenue.breach_record._validate +(WP-0006-T09). No Docker/Postgres required — persistence/publication +round-trips are covered by the Docker-gated tests in +tests/test_ledger_hosting.py. +""" + +from __future__ import annotations + +from target_revenue import breach_record + + +def _base_event(**overrides): + event = { + "id": "trsl:breach:example0010001", + "case_id": "trsl:case:example001", + "event_type": "alleged", + "category": "unauthorized-commercial-use", + "event_at": "2026-08-01T00:00:00Z", + } + event.update(overrides) + return event + + +def test_valid_anonymized_alleged_event_has_no_errors(): + assert breach_record._validate(_base_event()) == [] + + +def test_missing_required_field_rejected(): + event = _base_event() + del event["category"] + errors = breach_record._validate(event) + assert any("category" in e for e in errors) + + +def test_unknown_event_type_rejected(): + errors = breach_record._validate(_base_event(event_type="dismissed")) + assert any("event_type" in e for e in errors) + + +def test_named_disclosure_requires_holder_and_authorization(): + errors = breach_record._validate(_base_event(anonymized=False)) + assert any("named_entitlement_holder" in e for e in errors) + assert any("named_disclosure_authorized_under_cua" in e for e in errors) + + +def test_named_disclosure_with_holder_but_no_authorization_still_rejected(): + errors = breach_record._validate( + _base_event(anonymized=False, named_entitlement_holder="Acme Corp") + ) + assert any("named_disclosure_authorized_under_cua" in e for e in errors) + + +def test_named_disclosure_with_holder_and_authorization_accepted(): + errors = breach_record._validate( + _base_event( + anonymized=False, + named_entitlement_holder="Acme Corp", + named_disclosure_authorized_under_cua=True, + ) + ) + assert errors == [] + + +def test_anonymized_event_with_holder_set_rejected(): + """A caller must not be able to sneak a name in while anonymized=True.""" + errors = breach_record._validate( + _base_event(anonymized=True, named_entitlement_holder="Acme Corp") + ) + assert any("must not be set" in e for e in errors) diff --git a/tests/test_hosted_conformance.py b/tests/test_hosted_conformance.py index edba571..8f7a3d1 100644 --- a/tests/test_hosted_conformance.py +++ b/tests/test_hosted_conformance.py @@ -38,6 +38,7 @@ MIGRATIONS = [ REPO_ROOT / "migrations" / "0001_registries.sql", REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0003_attestations.sql", + REPO_ROOT / "migrations" / "0004_breach_records.sql", ] EXTENSION_NAMES = [ "development-license", diff --git a/tests/test_ledger_hosting.py b/tests/test_ledger_hosting.py index 1f8c55a..722fa7f 100644 --- a/tests/test_ledger_hosting.py +++ b/tests/test_ledger_hosting.py @@ -31,6 +31,7 @@ MIGRATIONS = [ REPO_ROOT / "migrations" / "0001_registries.sql", REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0003_attestations.sql", + REPO_ROOT / "migrations" / "0004_breach_records.sql", ] pytestmark = pytest.mark.skipif( @@ -402,3 +403,121 @@ def test_application_role_cannot_update_or_delete_attestations(pg_container): with pytest.raises(psycopg.errors.InsufficientPrivilege): conn.execute("DELETE FROM attestations WHERE phase_id = 'nonexistent'") conn.rollback() + + +def _breach_event(event_id: str, case_id: str, event_type: str, **overrides) -> dict: + event = { + "id": event_id, + "case_id": case_id, + "event_type": event_type, + "category": "unauthorized-commercial-use", + "event_at": "2026-08-01T00:00:00Z", + } + event.update(overrides) + return event + + +def test_breach_record_defaults_anonymized_and_lists_lifecycle_events( + client, pg_container, registered_phase +): + phase_id = registered_phase["phase"]["id"] + case_id = "trsl:case:conformcase0001" + + alleged = client.post( + f"/phases/{phase_id}/breach-records", + json=_breach_event("trsl:breach:conformcase00010001", case_id, "alleged"), + headers=auth_headers(pg_container["token"]), + ) + assert alleged.status_code == 201, alleged.text + assert alleged.json()["anonymized"] is True + assert alleged.json()["named_entitlement_holder"] is None + assert "signature" in alleged.json() + + determined = client.post( + f"/phases/{phase_id}/breach-records", + json=_breach_event( + "trsl:breach:conformcase00010002", case_id, "determined", + event_at="2026-08-15T00:00:00Z", + ), + headers=auth_headers(pg_container["token"]), + ) + assert determined.status_code == 201, determined.text + + records = client.get(f"/phases/{phase_id}/breach-records") + assert records.status_code == 200 + events = records.json() + assert [e["event_type"] for e in events] == ["alleged", "determined"] + # The "alleged" event must never have been edited by the "determined" + # publication — both remain, in order, as separate records. + assert events[0]["id"] == "trsl:breach:conformcase00010001" + assert events[1]["id"] == "trsl:breach:conformcase00010002" + + +def test_breach_record_named_disclosure_requires_explicit_cua_authorization( + client, pg_container, registered_phase +): + phase_id = registered_phase["phase"]["id"] + unauthorized = client.post( + f"/phases/{phase_id}/breach-records", + json=_breach_event( + "trsl:breach:conformnamed0001", "trsl:case:conformnamed", "alleged", + anonymized=False, named_entitlement_holder="Acme Corp", + ), + headers=auth_headers(pg_container["token"]), + ) + assert unauthorized.status_code == 422 + assert "named_disclosure_authorized_under_cua" in unauthorized.json()["detail"] + + authorized = client.post( + f"/phases/{phase_id}/breach-records", + json=_breach_event( + "trsl:breach:conformnamed0002", "trsl:case:conformnamed", "alleged", + anonymized=False, + named_entitlement_holder="Acme Corp", + named_disclosure_authorized_under_cua=True, + ), + headers=auth_headers(pg_container["token"]), + ) + assert authorized.status_code == 201, authorized.text + assert authorized.json()["named_entitlement_holder"] == "Acme Corp" + + +def test_other_licensor_cannot_publish_breach_record_for_this_phase( + client, pg_container, registered_phase +): + phase_id = registered_phase["phase"]["id"] + resp = client.post( + f"/phases/{phase_id}/breach-records", + json=_breach_event("trsl:breach:conformcross0001", "trsl:case:conformcross", "alleged"), + headers=auth_headers(pg_container["other_token"]), + ) + assert resp.status_code == 422 + assert "not authorized" in resp.json()["detail"] + + +def test_breach_record_signature_verifiable_with_public_key(client, pg_container, registered_phase): + phase_id = registered_phase["phase"]["id"] + published = client.post( + f"/phases/{phase_id}/breach-records", + json=_breach_event("trsl:breach:conformverify0001", "trsl:case:conformverify", "alleged"), + headers=auth_headers(pg_container["token"]), + ).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_breach_records(pg_container): + with psycopg.connect(pg_container["app_dsn"]) as conn: + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute("UPDATE breach_records SET category = 'x' WHERE record_id = 'nonexistent'") + conn.rollback() + with pytest.raises(psycopg.errors.InsufficientPrivilege): + conn.execute("DELETE FROM breach_records WHERE record_id = 'nonexistent'") + conn.rollback() diff --git a/tests/test_onboarding_hosted.py b/tests/test_onboarding_hosted.py index 493e32c..f8ec5a2 100644 --- a/tests/test_onboarding_hosted.py +++ b/tests/test_onboarding_hosted.py @@ -31,6 +31,7 @@ MIGRATIONS = [ REPO_ROOT / "migrations" / "0001_registries.sql", REPO_ROOT / "migrations" / "0002_ledger.sql", REPO_ROOT / "migrations" / "0003_attestations.sql", + REPO_ROOT / "migrations" / "0004_breach_records.sql", ] pytestmark = pytest.mark.skipif( diff --git a/workplans/TREV-WP-0006-trust-service-implementation.md b/workplans/TREV-WP-0006-trust-service-implementation.md index ef0dab0..618f753 100644 --- a/workplans/TREV-WP-0006-trust-service-implementation.md +++ b/workplans/TREV-WP-0006-trust-service-implementation.md @@ -4,7 +4,7 @@ type: workplan title: "Trust Service reference implementation (PRD Phase 4b)" domain: infotech repo: target-revenue -status: active +status: finished owner: claude topic_slug: infotech created: "2026-07-29" @@ -351,7 +351,7 @@ left running. ```task id: TREV-WP-0006-T09 -status: todo +status: done priority: medium state_hub_task_id: "1a25114c-ccbb-417a-aa73-68f33120c5e4" ``` @@ -384,3 +384,32 @@ workplan's ninth task, per TrustServicePRD TS-FR-7: similar), `src/target_revenue/breach_record.py`, corresponding `service/app.py` endpoints, and Docker-gated tests following the existing pattern in `tests/test_hosted_conformance.py`/`test_ledger_hosting.py`. + +**Result:** `migrations/0004_breach_records.sql` models a case's lifecycle +as append-only events (`alleged`/`cured`/`determined`/`terminated`) +grouped by `case_id`, rather than one mutable row per case — resolution is +always a new, later event, never an edit of the `alleged` one (`trf_app` +again has no UPDATE/DELETE grant). A `CHECK` constraint makes the +anonymized-default rule a database fact, not just an API convention: +`named_entitlement_holder` can be set if and only if `anonymized = false`. +`src/target_revenue/breach_record.py`'s `publish_breach_event()` enforces +per-Licensor phase ownership (same pattern as `ledger.py`), and — the +naming-authorization requirement License V1C1 §7.4/CUA §9 impose — rejects +any named-disclosure request that doesn't also set +`named_disclosure_authorized_under_cua: true`; the Trust Service never +verifies the underlying CUA text, it only records that the Licensor +asserted the clause authorizes it. Signs every published event with the +same instance Ed25519 key already used for Ledger entries and +Attestations. Added `POST/GET /phases/{id}/breach-records` to +`service/app.py` (write requires the Phase's own Licensor token; read is +public). Guarded the `.registry` import behind a lazy, in-function import +(matching `attestation.py`'s `TYPE_CHECKING` pattern) so +`tests/test_breach_record.py` (7 tests) runs its pure validation-logic +checks under plain system Python with no psycopg dependency. 5 new +Docker-gated tests in `tests/test_ledger_hosting.py` cover the default- +anonymized lifecycle (alleged → determined, both events preserved, +neither edited), the named-disclosure authorization gate (rejected without +it, accepted with it), cross-Licensor rejection, signature verification, +and DB-level UPDATE/DELETE rejection. Full suite: 56 passing offline +(plain system Python), 89 passing with Docker; no stray containers left +running. WP-0006 is finished again — all 9 tasks done.