Implement hosted Breach/Compliance Record publication (WP-0006-T09)

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 - resolution is always a new,
later event, never an edit. A CHECK constraint makes the
anonymized-default rule (License V1C1 §7.4) a database fact:
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 and rejects named-disclosure requests
that don't also set named_disclosure_authorized_under_cua: true - the
Trust Service records the Licensor's assertion that the CUA's naming
clause authorizes it, it never verifies the underlying CUA text
itself. Signs every event with the same instance Ed25519 key already
used for Ledger entries and Attestations.

Adds POST/GET /phases/{id}/breach-records. 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
under plain system Python with no psycopg dependency. 5 new
Docker-gated tests cover the default-anonymized lifecycle, the
named-disclosure authorization gate, cross-Licensor rejection,
signature verification, and DB-level UPDATE/DELETE rejection.

This closes WP-0006 again - all 9 tasks done.
This commit is contained in:
tegwick 2026-07-29 22:23:41 +02:00
parent a2f1dbfa2e
commit b7b985d96d
9 changed files with 471 additions and 4 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-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; T01T08 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 |

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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