Register informed-decision.memo.posed for the platform tenant.
Accept the retained pose envelope and refuse packet, brief, and secret content. Heartbeat and reconciliation follow the existing load-bearing class pattern. This does not connect the sink or claim runtime readiness. Assistant: grok Assistant-Session: 01a10329-31cb-78d3-b7a3-787a69c89d54
This commit is contained in:
parent
fdca5758e1
commit
70e4044128
16 changed files with 534 additions and 10 deletions
|
|
@ -45,6 +45,7 @@ from audit_core.redaction import (
|
||||||
SecretFieldRejection,
|
SecretFieldRejection,
|
||||||
apply_policy,
|
apply_policy,
|
||||||
finding_from_path,
|
finding_from_path,
|
||||||
|
scan,
|
||||||
)
|
)
|
||||||
from audit_core.senders import WILDCARD, SenderIdentity, SenderRegistry, development_registry
|
from audit_core.senders import WILDCARD, SenderIdentity, SenderRegistry, development_registry
|
||||||
from audit_core.sqlite_backend import SQLiteAuditBackend
|
from audit_core.sqlite_backend import SQLiteAuditBackend
|
||||||
|
|
@ -552,11 +553,71 @@ class IngestionApplication:
|
||||||
return [body]
|
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(
|
def normalize(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
idempotency_key: str | None,
|
idempotency_key: str | None,
|
||||||
identity: SenderIdentity | None = None,
|
identity: SenderIdentity | None = None,
|
||||||
) -> AuditEvent:
|
) -> AuditEvent:
|
||||||
|
if isinstance(payload, dict) and payload.get("type") == POSED_EVENT_TYPE:
|
||||||
|
_refuse_posed_content(payload)
|
||||||
required = (
|
required = (
|
||||||
"id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data",
|
"id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data",
|
||||||
)
|
)
|
||||||
|
|
@ -575,6 +636,8 @@ def normalize(
|
||||||
raise ValueError("tenant_not_allowed")
|
raise ValueError("tenant_not_allowed")
|
||||||
elif source != "user-engine":
|
elif source != "user-engine":
|
||||||
raise ValueError("source_not_allowed")
|
raise ValueError("source_not_allowed")
|
||||||
|
if payload.get("type") == POSED_EVENT_TYPE:
|
||||||
|
_require_posed_envelope(payload)
|
||||||
observed_at = _normalize_timestamp(payload["occurred_at"])
|
observed_at = _normalize_timestamp(payload["occurred_at"])
|
||||||
policy = identity.secret_policy if identity is not None else POLICY_REDACT
|
policy = identity.secret_policy if identity is not None else POLICY_REDACT
|
||||||
# Raises SecretFieldRejection under the reject policy; that exception
|
# Raises SecretFieldRejection under the reject policy; that exception
|
||||||
|
|
|
||||||
|
|
@ -166,10 +166,10 @@ MIGRATIONS: list[tuple[str, str]] = [
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Rejection reasons whose payload must never be persisted — storing the body of
|
# Rejection reasons whose payload must never be persisted. Storing the body of
|
||||||
# an event rejected *for carrying secret-shaped material* would write that
|
# an event rejected for secret-shaped material, or for packet/brief content,
|
||||||
# material into the audit store.
|
# would write that material into the audit store.
|
||||||
WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field"})
|
WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field", "content_refused"})
|
||||||
|
|
||||||
|
|
||||||
class PostgresAuditBackend:
|
class PostgresAuditBackend:
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,9 @@ CREATE TABLE IF NOT EXISTS secret_findings (
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Rejection reasons whose payload must never be persisted. Storing the body of
|
# Rejection reasons whose payload must never be persisted. Storing the body of
|
||||||
# an event rejected *for containing secret-shaped material* would write that
|
# an event rejected for secret-shaped material, or for packet/brief content,
|
||||||
# material into the audit store — the precise outcome the rejection prevents.
|
# would write that material into the audit store.
|
||||||
WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field"})
|
WITHHOLD_PAYLOAD_REASONS = frozenset({"secret_shaped_field", "content_refused"})
|
||||||
|
|
||||||
|
|
||||||
class SQLiteAuditBackend:
|
class SQLiteAuditBackend:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@
|
||||||
],
|
],
|
||||||
"may_write": true,
|
"may_write": true,
|
||||||
"may_read": false,
|
"may_read": false,
|
||||||
"evidence_kind": "load-bearing"
|
"evidence_kind": "load-bearing",
|
||||||
|
"heartbeat_classes": {
|
||||||
|
"informed-decision.memo.posed": 86400
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,9 @@ data:
|
||||||
],
|
],
|
||||||
"may_write": true,
|
"may_write": true,
|
||||||
"may_read": false,
|
"may_read": false,
|
||||||
"evidence_kind": "load-bearing"
|
"evidence_kind": "load-bearing",
|
||||||
|
"heartbeat_classes": {
|
||||||
|
"informed-decision.memo.posed": 86400
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ as the sender's claim rather than the archive's.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `202` | Accepted and stored |
|
| `202` | Accepted and stored |
|
||||||
| `200` | Duplicate `id` — already stored, reconciled rather than re-linked. Not an error; do not retry |
|
| `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 |
|
| `401` | Unknown or expired token |
|
||||||
| `403` | The credential lacks `may_write` |
|
| `403` | The credential lacks `may_write` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
revocation. No native producer event or operational heartbeat is claimed by
|
||||||
the custody receipt. Attestation/offsite operation remains a separate evidence
|
the custody receipt. Attestation/offsite operation remains a separate evidence
|
||||||
quality bound in AUDIT-WP-0009-T12.
|
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:<id>`; 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.
|
||||||
|
|
|
||||||
17
tests/fixtures/informed-decision-memo-posed/duplicate.json
vendored
Normal file
17
tests/fixtures/informed-decision-memo-posed/duplicate.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tests/fixtures/informed-decision-memo-posed/positive.json
vendored
Normal file
17
tests/fixtures/informed-decision-memo-posed/positive.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tests/fixtures/informed-decision-memo-posed/refusal-brief.json
vendored
Normal file
18
tests/fixtures/informed-decision-memo-posed/refusal-brief.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tests/fixtures/informed-decision-memo-posed/refusal-packet.json
vendored
Normal file
18
tests/fixtures/informed-decision-memo-posed/refusal-packet.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tests/fixtures/informed-decision-memo-posed/refusal-secret.json
vendored
Normal file
18
tests/fixtures/informed-decision-memo-posed/refusal-secret.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
tests/fixtures/informed-decision-memo-posed/refusal-tenant.json
vendored
Normal file
17
tests/fixtures/informed-decision-memo-posed/refusal-tenant.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
261
tests/test_informed_decision_memo_posed.py
Normal file
261
tests/test_informed_decision_memo_posed.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -353,7 +353,12 @@ def test_informed_decision_is_declared_load_bearing():
|
||||||
assert entry["may_read"] is False
|
assert entry["may_read"] is False
|
||||||
# §9.6 permits no completeness trade for a load-bearing source.
|
# §9.6 permits no completeness trade for a load-bearing source.
|
||||||
assert "completeness_trade" not in entry
|
assert "completeness_trade" not in entry
|
||||||
|
assert "emission_cadence" not in entry
|
||||||
assert "tokens" not in entry and "token" 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():
|
def test_informed_decision_scope_entry_admits_nothing_without_a_token():
|
||||||
|
|
|
||||||
40
workplans/AUDIT-WP-0011-informed-decision-memo-posed.md
Normal file
40
workplans/AUDIT-WP-0011-informed-decision-memo-posed.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue