diff --git a/audit_core/ingestion.py b/audit_core/ingestion.py index b796eb9..7daf3c9 100644 --- a/audit_core/ingestion.py +++ b/audit_core/ingestion.py @@ -45,6 +45,7 @@ from audit_core.redaction import ( SecretFieldRejection, apply_policy, finding_from_path, + scan, ) from audit_core.senders import WILDCARD, SenderIdentity, SenderRegistry, development_registry from audit_core.sqlite_backend import SQLiteAuditBackend @@ -552,11 +553,71 @@ class IngestionApplication: return [body] +# Local registration of informed-decision.memo.posed (intake dependency T07). +# The retained envelope is the one informed-decision already queues: the eight +# wire fields, with data limited to the six ids and digests below. Packet, +# brief, and secret-shaped keys are refused before any other error so a later +# invalid_event cannot write that body into the dead-letter store. +POSED_EVENT_TYPE = "informed-decision.memo.posed" +POSED_SOURCE = "informed-decision" +POSED_TENANT = "tenant:platform" +POSED_DATA_KEYS = frozenset({ + "memo_id", + "approval_id", + "approval_binding_digest", + "submitter", + "memo_class", + "submission_digest", +}) +CONTENT_REFUSED = "content_refused" +_POSED_CONTENT_KEYS = frozenset({"packet", "brief"}) + + +def _has_posed_content_key(value: Any) -> bool: + if isinstance(value, dict): + for key, item in value.items(): + if str(key).lower() in _POSED_CONTENT_KEYS or _has_posed_content_key(item): + return True + elif isinstance(value, list): + return any(_has_posed_content_key(item) for item in value) + return False + + +def _refuse_posed_content(payload: dict[str, Any]) -> None: + """Refuse packet, brief, and secret-shaped keys on this class only. + + Other event types keep the sender's secret_policy. This class does not: + the dependency says refuse, and redacting would still accept the event. + """ + findings = scan(payload) + if findings: + raise SecretFieldRejection(findings) + if _has_posed_content_key(payload): + raise ValueError(CONTENT_REFUSED) + + +def _require_posed_envelope(payload: dict[str, Any]) -> None: + """Accept only the locally retained field set, for this source and tenant.""" + if str(payload.get("source") or "") != POSED_SOURCE: + raise ValueError("source_not_allowed") + if str(payload.get("tenant") or "") != POSED_TENANT: + raise ValueError("tenant_not_allowed") + data = payload.get("data") + if ( + not isinstance(data, dict) + or set(data) != POSED_DATA_KEYS + or any(type(data[key]) is not str or not data[key].strip() for key in POSED_DATA_KEYS) + ): + raise ValueError("invalid_event") + + def normalize( payload: dict[str, Any], idempotency_key: str | None, identity: SenderIdentity | None = None, ) -> AuditEvent: + if isinstance(payload, dict) and payload.get("type") == POSED_EVENT_TYPE: + _refuse_posed_content(payload) required = ( "id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data", ) @@ -575,6 +636,8 @@ def normalize( raise ValueError("tenant_not_allowed") elif source != "user-engine": raise ValueError("source_not_allowed") + if payload.get("type") == POSED_EVENT_TYPE: + _require_posed_envelope(payload) observed_at = _normalize_timestamp(payload["occurred_at"]) policy = identity.secret_policy if identity is not None else POLICY_REDACT # Raises SecretFieldRejection under the reject policy; that exception diff --git a/audit_core/postgres_backend.py b/audit_core/postgres_backend.py index 0c190dd..5eac569 100644 --- a/audit_core/postgres_backend.py +++ b/audit_core/postgres_backend.py @@ -166,10 +166,10 @@ MIGRATIONS: list[tuple[str, str]] = [ ), ] -# Rejection reasons whose payload must never be persisted — storing the body of -# an event rejected *for carrying secret-shaped material* would write that -# material into the audit store. -WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field"}) +# Rejection reasons whose payload must never be persisted. Storing the body of +# an event rejected for secret-shaped material, or for packet/brief content, +# would write that material into the audit store. +WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field", "content_refused"}) class PostgresAuditBackend: diff --git a/audit_core/sqlite_backend.py b/audit_core/sqlite_backend.py index cb8fb15..846ec54 100644 --- a/audit_core/sqlite_backend.py +++ b/audit_core/sqlite_backend.py @@ -74,9 +74,9 @@ CREATE TABLE IF NOT EXISTS secret_findings ( """ # Rejection reasons whose payload must never be persisted. Storing the body of -# an event rejected *for containing secret-shaped material* would write that -# material into the audit store — the precise outcome the rejection prevents. -WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field"}) +# an event rejected for secret-shaped material, or for packet/brief content, +# would write that material into the audit store. +WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field", "content_refused"}) class SQLiteAuditBackend: diff --git a/deploy/senders-scope.json b/deploy/senders-scope.json index 94f1b53..021bdd6 100644 --- a/deploy/senders-scope.json +++ b/deploy/senders-scope.json @@ -45,6 +45,9 @@ ], "may_write": true, "may_read": false, - "evidence_kind": "load-bearing" + "evidence_kind": "load-bearing", + "heartbeat_classes": { + "informed-decision.memo.posed": 86400 + } } ] diff --git a/deploy/senders-scope.yaml b/deploy/senders-scope.yaml index 872ac1e..cd812be 100644 --- a/deploy/senders-scope.yaml +++ b/deploy/senders-scope.yaml @@ -67,6 +67,9 @@ data: ], "may_write": true, "may_read": false, - "evidence_kind": "load-bearing" + "evidence_kind": "load-bearing", + "heartbeat_classes": { + "informed-decision.memo.posed": 86400 + } } ] diff --git a/docs/event-envelope.md b/docs/event-envelope.md index d824df4..e93a3f5 100644 --- a/docs/event-envelope.md +++ b/docs/event-envelope.md @@ -79,7 +79,7 @@ as the sender's claim rather than the archive's. | --- | --- | | `202` | Accepted and stored | | `200` | Duplicate `id` — already stored, reconciled rather than re-linked. Not an error; do not retry | -| `400` | Rejected and **dead-lettered**. Body carries the reason: `invalid_event`, `idempotency_key_mismatch`, `source_not_allowed`, `tenant_not_allowed`, `secret_shaped_field` | +| `400` | Rejected and **dead-lettered**. Body carries the reason: `invalid_event`, `idempotency_key_mismatch`, `source_not_allowed`, `tenant_not_allowed`, `secret_shaped_field`, `content_refused` | | `401` | Unknown or expired token | | `403` | The credential lacks `may_write` | diff --git a/docs/informed-decision-source-registration.md b/docs/informed-decision-source-registration.md index c3751d6..ca86ea4 100644 --- a/docs/informed-decision-source-registration.md +++ b/docs/informed-decision-source-registration.md @@ -213,3 +213,47 @@ receiver source/tenant/read refusal, independent readback and audit bearer revocation. No native producer event or operational heartbeat is claimed by the custody receipt. Attestation/offsite operation remains a separate evidence quality bound in AUDIT-WP-0009-T12. + +## Event class `informed-decision.memo.posed` — 2026-10-03 + +`AUDIT-WP-0011`. Intake dependency T07. Same sender, same source, same +`tenant:platform` scope. Not a new credential, not a new custody class, and +not a change to `evidence_kind`, `secret_policy`, or `completeness_trade`. + +The accepted body is the envelope informed-decision already retains for the +posed job. `data` is exactly these fields, each a non-empty string: + +| Field | What it is | +| --- | --- | +| `memo_id` | memo id | +| `approval_id` | approval id | +| `approval_binding_digest` | carried binding digest | +| `submitter` | authenticated forwarding submitter | +| `memo_class` | memo class | +| `submission_digest` | submission digest | + +`source` must be `informed-decision` and `tenant` must be `tenant:platform`. +The bare tenant string `platform` is not registered. No other `data` key is +part of this registration. + +Packet and brief keys are `content_refused`. Secret-shaped keys (the existing +name scan: password, secret, token, credential, private_key) are +`secret_shaped_field` for this class even though the sender's policy remains +`redact` for every other class. Both refusals withhold the payload from the +dead-letter store. This receiver still does not verify the digest, read the +memo, or decide whether the approval is valid. + +Idempotency is the existing receipt: `Idempotency-Key` must equal `id`; `202` +`accepted` with `reference` `audit:`; an exact replay is `200` +`duplicate` with the same reference. + +The class is load-bearing and low-volume, so it uses the existing +heartbeat-or-reconciliation requirement rather than a rate. `heartbeat_classes` +on this registration is `informed-decision.memo.posed` at 86400 seconds — the +interval this sender already publishes for its other low-volume classes and +the interval the approval-engine registration tests use. Reconciliation is +`GET /v1/reconciliation` for source `informed-decision` and tenant +`tenant:platform`, counts only. Neither control is claimed to be operating, +and this registration does not make the receiver runtime-ready for the posed +job. Connecting `AuditCoreSink` stays informed-decision's step, after this +contract. diff --git a/tests/fixtures/informed-decision-memo-posed/duplicate.json b/tests/fixtures/informed-decision-memo-posed/duplicate.json new file mode 100644 index 0000000..d4bd80d --- /dev/null +++ b/tests/fixtures/informed-decision-memo-posed/duplicate.json @@ -0,0 +1,17 @@ +{ + "id": "posed-fixture-1", + "type": "informed-decision.memo.posed", + "source": "informed-decision", + "subject": "memo-fixture-1", + "tenant": "tenant:platform", + "correlation_id": "approval-fixture-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": { + "memo_id": "memo-fixture-1", + "approval_id": "approval-fixture-1", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitter": "state-hub", + "memo_class": "fixture-class", + "submission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/tests/fixtures/informed-decision-memo-posed/positive.json b/tests/fixtures/informed-decision-memo-posed/positive.json new file mode 100644 index 0000000..d4bd80d --- /dev/null +++ b/tests/fixtures/informed-decision-memo-posed/positive.json @@ -0,0 +1,17 @@ +{ + "id": "posed-fixture-1", + "type": "informed-decision.memo.posed", + "source": "informed-decision", + "subject": "memo-fixture-1", + "tenant": "tenant:platform", + "correlation_id": "approval-fixture-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": { + "memo_id": "memo-fixture-1", + "approval_id": "approval-fixture-1", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitter": "state-hub", + "memo_class": "fixture-class", + "submission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/tests/fixtures/informed-decision-memo-posed/refusal-brief.json b/tests/fixtures/informed-decision-memo-posed/refusal-brief.json new file mode 100644 index 0000000..d861b1f --- /dev/null +++ b/tests/fixtures/informed-decision-memo-posed/refusal-brief.json @@ -0,0 +1,18 @@ +{ + "id": "posed-refusal-brief", + "type": "informed-decision.memo.posed", + "source": "informed-decision", + "subject": "memo-fixture-1", + "tenant": "tenant:platform", + "correlation_id": "approval-fixture-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": { + "memo_id": "memo-fixture-1", + "approval_id": "approval-fixture-1", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitter": "state-hub", + "memo_class": "fixture-class", + "submission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "brief": "fixture-brief" + } +} diff --git a/tests/fixtures/informed-decision-memo-posed/refusal-packet.json b/tests/fixtures/informed-decision-memo-posed/refusal-packet.json new file mode 100644 index 0000000..b55ec9b --- /dev/null +++ b/tests/fixtures/informed-decision-memo-posed/refusal-packet.json @@ -0,0 +1,18 @@ +{ + "id": "posed-refusal-packet", + "type": "informed-decision.memo.posed", + "source": "informed-decision", + "subject": "memo-fixture-1", + "tenant": "tenant:platform", + "correlation_id": "approval-fixture-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": { + "memo_id": "memo-fixture-1", + "approval_id": "approval-fixture-1", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitter": "state-hub", + "memo_class": "fixture-class", + "submission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "packet": "fixture-packet" + } +} diff --git a/tests/fixtures/informed-decision-memo-posed/refusal-secret.json b/tests/fixtures/informed-decision-memo-posed/refusal-secret.json new file mode 100644 index 0000000..01663ae --- /dev/null +++ b/tests/fixtures/informed-decision-memo-posed/refusal-secret.json @@ -0,0 +1,18 @@ +{ + "id": "posed-refusal-secret", + "type": "informed-decision.memo.posed", + "source": "informed-decision", + "subject": "memo-fixture-1", + "tenant": "tenant:platform", + "correlation_id": "approval-fixture-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": { + "memo_id": "memo-fixture-1", + "approval_id": "approval-fixture-1", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitter": "state-hub", + "memo_class": "fixture-class", + "submission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "password": "fixture-not-a-credential" + } +} diff --git a/tests/fixtures/informed-decision-memo-posed/refusal-tenant.json b/tests/fixtures/informed-decision-memo-posed/refusal-tenant.json new file mode 100644 index 0000000..65b0788 --- /dev/null +++ b/tests/fixtures/informed-decision-memo-posed/refusal-tenant.json @@ -0,0 +1,17 @@ +{ + "id": "posed-refusal-tenant", + "type": "informed-decision.memo.posed", + "source": "informed-decision", + "subject": "memo-fixture-1", + "tenant": "platform", + "correlation_id": "approval-fixture-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": { + "memo_id": "memo-fixture-1", + "approval_id": "approval-fixture-1", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitter": "state-hub", + "memo_class": "fixture-class", + "submission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/tests/test_informed_decision_memo_posed.py b/tests/test_informed_decision_memo_posed.py new file mode 100644 index 0000000..4b833ba --- /dev/null +++ b/tests/test_informed_decision_memo_posed.py @@ -0,0 +1,261 @@ +"""Local registration of informed-decision.memo.posed. + +Same sender pattern as the other registered sources: scope binds the +credential, Idempotency-Key receipts are 202 then 200, and a low-volume +load-bearing class is a heartbeat class counted by /v1/reconciliation. +""" + +import json +from pathlib import Path + +import pytest + +from audit_core.ingestion import ( + CONTENT_REFUSED, + POSED_EVENT_TYPE, + IngestionApplication, +) +from audit_core.senders import SenderRegistry +from audit_core.sqlite_backend import SQLiteAuditBackend +from audit_core.stream_findings import HEARTBEAT_ACTION, NEVER_HEARTBEAT + +from test_ingestion import invoke +from test_senders import SCOPE_FILE + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "informed-decision-memo-posed" +WINDOW = ( + "/v1/reconciliation?source=informed-decision&tenant=tenant:platform" + "&since=2026-01-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00" +) + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + + +def _registry() -> SenderRegistry: + return SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps([ + { + "name": "informed-decision", + "tokens": ["fixture-only"], + "sources": ["*"], + "tenants": ["*"], + "may_read": True, + "secret_policy": "redact", + "evidence_kind": "load-bearing", + }, + { + "name": "user-engine", + "tokens": ["user-fixture"], + "sources": ["*"], + "tenants": ["*"], + "may_read": False, + "secret_policy": "redact", + }, + { + "name": "operator", + "tokens": ["operator-fixture"], + "sources": ["informed-decision"], + "tenants": ["*"], + "may_write": False, + "may_read": True, + }, + ]), + "AUDIT_CORE_SENDERS_SCOPE_PATH": str(SCOPE_FILE), + }) + + +@pytest.fixture +def app(tmp_path): + backend = SQLiteAuditBackend(str(tmp_path / "posed.db")) + return IngestionApplication(backend, _registry()), backend + + +def _post(app, payload, *, token="fixture-only", key=None): + return invoke(app, payload, token=token, key=payload["id"] if key is None else key) + + +def test_scope_registration_keeps_sender_custody_and_adds_the_class(): + identity = _registry().authenticate("Bearer fixture-only") + assert identity.name == "informed-decision" + assert identity.is_load_bearing + assert identity.completeness_trade is None + assert identity.secret_policy == "redact" + assert identity.may_write and not identity.may_read + assert identity.permits_source("informed-decision") + assert not identity.permits_source("user-engine") + assert identity.permits_tenant("tenant:platform") + assert not identity.permits_tenant("platform") + assert dict(identity.heartbeat_classes) == {POSED_EVENT_TYPE: 86400} + + +def test_positive_fixture_is_accepted_and_duplicate_fixture_reconciles(app): + application, backend = app + positive = _load("positive.json") + duplicate = _load("duplicate.json") + assert duplicate == positive + + status, body = _post(application, positive) + assert status.startswith("202") + assert body == {"status": "accepted", "reference": "audit:posed-fixture-1"} + + status, body = _post(application, duplicate) + assert status.startswith("200") + assert body == {"status": "duplicate", "reference": "audit:posed-fixture-1"} + assert backend.db.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1 + + status, stored = invoke( + application, None, path="/v1/events/posed-fixture-1", method="GET", + body=b"", token="operator-fixture", + ) + assert status.startswith("200") + assert stored["action"] == POSED_EVENT_TYPE + assert stored["source"] == "informed-decision" + assert stored["tenant"] == "tenant:platform" + assert stored["details"]["data"] == positive["data"] + assert "redaction" not in stored["details"] + + status, denied = invoke( + application, None, path="/v1/events/posed-fixture-1", method="GET", + body=b"", token="fixture-only", + ) + assert status.startswith("403") + assert denied["error"] == "read_forbidden" + + +def test_idempotency_key_must_equal_the_event_id(app): + application, _ = app + status, body = _post(application, _load("positive.json"), key="other-key") + assert status.startswith("400") + assert body["error"] == "idempotency_key_mismatch" + + +def _get(app, path, *, token="fixture-only"): + import io + + base, _, query = path.partition("?") + environ = { + "PATH_INFO": base, + "QUERY_STRING": query, + "REQUEST_METHOD": "GET", + "CONTENT_LENGTH": "0", + "wsgi.input": io.BytesIO(b""), + "HTTP_AUTHORIZATION": f"Bearer {token}", + } + result = {} + out = b"".join(app(environ, lambda status, headers: result.update(status=status))) + return result["status"], (json.loads(out) if out else {}) + + +def test_reconciliation_counts_the_class_and_returns_no_payload(app): + application, _ = app + assert _post(application, _load("positive.json"))[0].startswith("202") + status, body = _get(application, WINDOW) + assert status.startswith("200") + assert body["source"] == "informed-decision" + assert body["tenant"] == "tenant:platform" + assert body["counts"] == [{"class": POSED_EVENT_TYPE, "count": 1}] + assert "memo-fixture-1" not in json.dumps(body) + assert "completeness" in body["means"] + + +def test_a_missing_heartbeat_for_the_class_is_a_finding_until_one_arrives(app): + application, _ = app + status, body = invoke( + application, None, path="/v1/stream-findings", method="GET", + body=b"", token="operator-fixture", + ) + assert status.startswith("200") + posed = [row for row in body["stream_findings"] if row["class"] == POSED_EVENT_TYPE] + assert [row["kind"] for row in posed] == [NEVER_HEARTBEAT] + assert posed[0]["source"] == "informed-decision" + assert posed[0]["expected_within_seconds"] == 86400 + assert "not proof of suppression" in posed[0]["means"].lower() + + heartbeat = { + "id": "hb-posed-1", + "type": HEARTBEAT_ACTION, + "source": "informed-decision", + "subject": "informed-decision", + "tenant": "tenant:platform", + "correlation_id": "hb-posed-1", + "occurred_at": "2026-10-03T12:00:00+00:00", + "data": {"class": POSED_EVENT_TYPE, "assertion": "nothing-to-report"}, + } + assert _post(application, heartbeat)[0].startswith("202") + _, cleared = invoke( + application, None, path="/v1/stream-findings", method="GET", + body=b"", token="operator-fixture", + ) + assert cleared["stream_findings"] == [] + + +def test_another_sender_cannot_write_the_class(app): + application, backend = app + status, body = _post(application, _load("positive.json"), token="user-fixture") + assert status.startswith("400") + assert body["error"] == "source_not_allowed" + assert backend.db.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 0 + + +@pytest.mark.parametrize("name,error,marker", [ + ("refusal-packet.json", CONTENT_REFUSED, "fixture-packet"), + ("refusal-brief.json", CONTENT_REFUSED, "fixture-brief"), + ("refusal-secret.json", "secret_shaped_field", "fixture-not-a-credential"), +]) +def test_refusal_fixtures_are_not_retained(app, name, error, marker): + application, backend = app + payload = _load(name) + status, body = _post(application, payload) + assert status.startswith("400") + assert body["error"] == error + assert backend.db.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 0 + + _, letters = invoke( + application, None, path="/v1/dead-letters", method="GET", + body=b"", token="operator-fixture", + ) + entry = letters["dead_letters"][0] + assert entry["reason"] == error + assert entry["payload_withheld"] is True + assert entry["payload"] is None + assert marker not in json.dumps(letters) + + +def test_bare_platform_tenant_is_not_the_registered_tenant(app): + application, _ = app + status, body = _post(application, _load("refusal-tenant.json")) + assert status.startswith("400") + assert body["error"] == "tenant_not_allowed" + + +def test_other_classes_still_follow_the_sender_redaction_policy(app): + """The posed refusal is not a new rule for the sender's other classes.""" + application, _ = app + payload = _load("positive.json") + payload = { + **payload, + "id": "presentation-1", + "type": "informed-decision.presentation", + "data": {"packet": "still-stored", "auth_token": "fixture-token"}, + } + assert _post(application, payload)[0].startswith("202") + _, stored = invoke( + application, None, path="/v1/events/presentation-1", method="GET", + body=b"", token="operator-fixture", + ) + assert stored["details"]["data"]["packet"] == "still-stored" + assert stored["details"]["data"]["auth_token"] == "[redacted]" + + +def test_both_backends_withhold_posed_content_refusals(): + from audit_core.sqlite_backend import WITHHOLD_PAYLOAD_REASONS as sqlite_reasons + + assert "content_refused" in sqlite_reasons + assert "secret_shaped_field" in sqlite_reasons + try: + from audit_core.postgres_backend import WITHHOLD_PAYLOAD_REASONS as postgres_reasons + except ImportError: + pytest.skip("postgres backend extra is not installed") + assert postgres_reasons == sqlite_reasons diff --git a/tests/test_senders.py b/tests/test_senders.py index 8b745da..351e752 100644 --- a/tests/test_senders.py +++ b/tests/test_senders.py @@ -353,7 +353,12 @@ def test_informed_decision_is_declared_load_bearing(): assert entry["may_read"] is False # §9.6 permits no completeness trade for a load-bearing source. assert "completeness_trade" not in entry + assert "emission_cadence" not in entry assert "tokens" not in entry and "token" not in entry + # Same interval this sender already publishes for its low-volume classes, + # and the same interval the approval-engine registration tests use. Not a + # new custody class and not a rate declaration. + assert entry["heartbeat_classes"] == {"informed-decision.memo.posed": 86400} def test_informed_decision_scope_entry_admits_nothing_without_a_token(): diff --git a/workplans/AUDIT-WP-0011-informed-decision-memo-posed.md b/workplans/AUDIT-WP-0011-informed-decision-memo-posed.md new file mode 100644 index 0000000..300a656 --- /dev/null +++ b/workplans/AUDIT-WP-0011-informed-decision-memo-posed.md @@ -0,0 +1,40 @@ +--- +id: AUDIT-WP-0011 +type: workplan +title: "Register informed-decision.memo.posed" +domain: infotech +repo: audit-core +status: finished +flavor: implementation +owner: grok +topic_slug: railiance +created: "2026-10-03" +updated: "2026-10-03" +--- + +# Register informed-decision.memo.posed + +Local registration only, for intake dependency T07 (hub message +`23b670ac-809e-49bf-9233-e0b62cf0a039`). No listener, no deployment, and no +claim that informed-decision may connect `AuditCoreSink`. + +## Register the posed event class + +```task +id: AUDIT-WP-0011-T01 +status: done +priority: high +``` + +Register `informed-decision.memo.posed` on the existing `informed-decision` +sender for `tenant:platform`. Accept the locally retained envelope (memo id, +approval id, carried binding digest, authenticated forwarding submitter, memo +class, submission digest). Refuse packet content, brief content, and secrets +for this class. Confirm sender custody, Idempotency-Key receipts, and the +existing per-class heartbeat and reconciliation requirement in tests, with +positive, duplicate, and refusal fixtures. + +Done locally. Evidence kind, secret policy, completeness, and custody class +are unchanged. The heartbeat interval is the sender's existing low-volume +interval, 86400 seconds, not a new detection rule. Runtime readiness is not +claimed. `INFD-WP-0005` is not updated by this file.