diff --git a/SCOPE.md b/SCOPE.md index 45daf6d..6ea4982 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -103,11 +103,19 @@ effect of its own. freshness window. Until `AUDIT-WP-0009-T02` schedules attestation, the honest answer in production is `false` — the overclaim is gone, the precondition is not yet met. -- No cadence, heartbeat, reconciliation, or load-bearing classification exists. - The §9.6 obligations Audit Core argued for are not yet supportable by Audit - Core. -- `approval-engine` is not yet registered as a source. -- No negative test asserts the absence of an approval-validity surface. +- No cadence, heartbeat, or reconciliation surface exists. The §9.6 detection + obligations Audit Core argued for are not yet supportable by Audit Core, so + a suppressed load-bearing event still produces silence + (`AUDIT-WP-0009-T04`/`T05`/`T06`). +- Load-bearing classification **does** now exist: `evidence_kind` on + `SenderIdentity` and the registration schema, defaulting to `attributive` + (`AUDIT-WP-0009-T03`, closed 2026-09-06). +- `approval-engine` registration inputs are prepared and inert pending its + token, a confirmed tenant scope, and an operator apply — + `docs/approval-engine-source-registration.md` (`AUDIT-WP-0009-T09`). +- ~~No negative test asserts the absence of an approval-validity surface.~~ + **Closed 2026-09-06 (`AUDIT-WP-0009-T08`)**, + `tests/test_approval_validity_prohibition.py`. ## Getting Oriented diff --git a/audit_core/senders.py b/audit_core/senders.py index 4b34129..9bae94f 100644 --- a/audit_core/senders.py +++ b/audit_core/senders.py @@ -25,6 +25,19 @@ from audit_core.redaction import POLICIES, POLICY_REDACT WILDCARD = "*" +# §9.6 evidence kinds. The obligations differ, so the archive must record +# which one a source declared rather than infer it from traffic. +# +# load-bearing: a control's soundness depends on the event being present, so +# the source owes emission atomicity and a detection surface (heartbeat or +# reconciliation) for rare classes. +# attributive: the trail is useful but no control depends on its completeness, +# which is what lets a deliberately non-atomic trail stay legitimate — while +# it is declared, and never described as complete. +EVIDENCE_LOAD_BEARING = "load-bearing" +EVIDENCE_ATTRIBUTIVE = "attributive" +EVIDENCE_KINDS = (EVIDENCE_LOAD_BEARING, EVIDENCE_ATTRIBUTIVE) + @dataclass(frozen=True) class SenderIdentity: @@ -44,6 +57,17 @@ class SenderIdentity: # legitimate event is not lost over one field; a higher-assurance channel # can be set to reject instead (AUDIT-WP-0004-T04). secret_policy: str = POLICY_REDACT + # §9.6 evidence kind (AUDIT-WP-0009-T03). Defaults to ``attributive`` on + # purpose: a source that has not declared must not be silently treated as + # load-bearing, because that would let audit-core imply a completeness + # obligation the source never accepted. Declaring load-bearing is an + # explicit act by the source and its owner. + evidence_kind: str = EVIDENCE_ATTRIBUTIVE + # The trade an attributive source has deliberately made — typically + # emitting after commit rather than inside the state-change transaction. + # §9.6 requires the trade be declared where the trail is documented, so it + # travels with the identity rather than living only in prose. + completeness_trade: str | None = None def __post_init__(self) -> None: if not self.name: @@ -59,6 +83,43 @@ class SenderIdentity: raise ValueError(f"sender {self.name!r} needs at least one permitted source") if self.expires_at is not None and self.expires_at.tzinfo is None: raise ValueError(f"sender {self.name!r}: expires_at must include a timezone") + if self.evidence_kind not in EVIDENCE_KINDS: + raise ValueError( + f"sender {self.name!r}: evidence_kind must be one of " + f"{EVIDENCE_KINDS}, got {self.evidence_kind!r}" + ) + if self.completeness_trade is not None and not str(self.completeness_trade).strip(): + raise ValueError( + f"sender {self.name!r}: completeness_trade must say what the trade " + "is, or be omitted" + ) + if self.is_load_bearing and self.completeness_trade is not None: + # A load-bearing source has no trade to make: §9.6 requires + # atomicity of it. Carrying a declared trade alongside would + # record a contradiction as though it were a policy. + raise ValueError( + f"sender {self.name!r}: a load-bearing source may not declare a " + "completeness_trade — §9.6 requires emission atomicity of it" + ) + + @property + def is_load_bearing(self) -> bool: + return self.evidence_kind == EVIDENCE_LOAD_BEARING + + def evidence_declaration(self) -> dict[str, Any]: + """What audit-core will say about this source's stream. + + Deliberately states the bound rather than only the kind. Neither kind + licenses the claim that the archive proves an event occurred, or that + absence proves it did not (§9.6). + """ + return { + "sender": self.name, + "evidence_kind": self.evidence_kind, + "completeness_claimed": False, + "completeness_trade": self.completeness_trade, + "detection_surface": None, + } def permits_source(self, source: str) -> bool: return WILDCARD in self.sources or source in self.sources @@ -221,11 +282,37 @@ def _apply_scope_overlay( ), secret_policy=identity.secret_policy, expires_at=identity.expires_at, + evidence_kind=_overlay_evidence_kind(identity, extra), + completeness_trade=( + _clean_trade(extra["completeness_trade"]) + if "completeness_trade" in extra + else identity.completeness_trade + ), ) ) return merged +def _overlay_evidence_kind(identity: SenderIdentity, extra: dict[str, Any]) -> str: + """Let the overlay raise the evidence kind, never lower it. + + Same principle as tenants: the ConfigMap is the authority for non-secret + policy, but a refresh must not be able to quietly weaken a declaration. + Downgrading a load-bearing source to attributive would drop its atomicity + and detection obligations without anyone deciding to, so it is refused + here and has to be a deliberate change to the Secret-backed declaration. + """ + declared = str(extra.get("evidence_kind") or identity.evidence_kind) + if declared == identity.evidence_kind: + return declared + if identity.is_load_bearing and declared == EVIDENCE_ATTRIBUTIVE: + raise ValueError( + f"sender {identity.name!r}: the scope overlay may not downgrade a " + "load-bearing source to attributive" + ) + return declared + + def _parse_identities(raw: str) -> list[SenderIdentity]: try: entries = json.loads(raw) @@ -249,9 +336,17 @@ def _identity_from(entry: Any) -> SenderIdentity: may_read=bool(entry.get("may_read", False)), secret_policy=str(entry.get("secret_policy", POLICY_REDACT)), expires_at=_parse_expiry(entry.get("expires_at")), + evidence_kind=str(entry.get("evidence_kind", EVIDENCE_ATTRIBUTIVE)), + completeness_trade=_clean_trade(entry.get("completeness_trade")), ) +def _clean_trade(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value) + + def _parse_expiry(value: Any) -> datetime | None: if value in (None, ""): return None diff --git a/deploy/networkpolicies.yaml b/deploy/networkpolicies.yaml index 99624ca..bac5835 100644 --- a/deploy/networkpolicies.yaml +++ b/deploy/networkpolicies.yaml @@ -63,6 +63,36 @@ spec: --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy +metadata: + name: audit-core-approval-engine-ingress + namespace: audit-core +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: audit-core + policyTypes: [Ingress] + ingress: + # AUDIT-WP-0009-T09 / AUDIT-IN-0001. Load-bearing approval evidence + # (§9.4). Both selectors belong to one peer and are therefore ANDed: + # only the approval-engine workload in its own namespace reaches this + # port. Splitting them into two list items would turn AND into OR and + # admit every pod in either set. + # + # Narrower than user-engine's namespace-only rule on purpose: this is a + # new sender, and a new rule should not inherit an older rule's breadth. + # user-engine's policy is deliberately left unchanged. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: approval-engine + podSelector: + matchLabels: + app.kubernetes.io/name: approval-engine + ports: + - {protocol: TCP, port: 8080} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy metadata: name: audit-core-operator-ingress namespace: audit-core diff --git a/deploy/senders-scope.json b/deploy/senders-scope.json index 8b37433..d5366bc 100644 --- a/deploy/senders-scope.json +++ b/deploy/senders-scope.json @@ -1,9 +1,25 @@ [ { "name": "user-engine", - "sources": ["user-engine"], - "tenants": ["*"], + "sources": [ + "user-engine" + ], + "tenants": [ + "*" + ], "may_write": true, "may_read": false + }, + { + "name": "approval-engine", + "sources": [ + "approval-engine" + ], + "tenants": [ + "*" + ], + "may_write": true, + "may_read": false, + "evidence_kind": "load-bearing" } ] diff --git a/deploy/senders-scope.yaml b/deploy/senders-scope.yaml index e462aa1..56a9aa6 100644 --- a/deploy/senders-scope.yaml +++ b/deploy/senders-scope.yaml @@ -2,6 +2,14 @@ # audit-core-senders. This ConfigMap is the authority for tenants/sources # so an ExternalSecret refresh cannot revert user-engine to a single tenant. # Keep in lockstep with deploy/senders-scope.json. +# +# The overlay only ever applies to a sender the Secret already carries, so an +# entry here for a sender with no token yet is inert. That is what makes the +# approval-engine entry safe to land ahead of its credential. +# +# evidence_kind may be raised here (attributive -> load-bearing) but never +# lowered: a ConfigMap refresh must not be able to drop a source's §9.6 +# atomicity and detection obligations without anyone deciding to. --- apiVersion: v1 kind: ConfigMap @@ -19,5 +27,13 @@ data: "tenants": ["*"], "may_write": true, "may_read": false + }, + { + "name": "approval-engine", + "sources": ["approval-engine"], + "tenants": ["*"], + "may_write": true, + "may_read": false, + "evidence_kind": "load-bearing" } ] diff --git a/docs/approval-engine-source-registration.md b/docs/approval-engine-source-registration.md new file mode 100644 index 0000000..1f61288 --- /dev/null +++ b/docs/approval-engine-source-registration.md @@ -0,0 +1,122 @@ +# approval-engine source registration + +`AUDIT-WP-0009-T09` · intake `AUDIT-IN-0001` · statute §9.4, §9.6 + +The registration inputs for `approval-engine` as a distinct audit source, +onboarded under `INTENT.md` principle 4 — declared ownership, retention, +access, export and evidence policy, not merely events arriving. + +This file is the owner-side record. Everything in it is non-secret. No sender +token appears here, in Git, in State Hub, or in a workplan. + +## Declaration + +| Field | Value | +| --- | --- | +| Sender name | `approval-engine` | +| Permitted `source` | `approval-engine` (exact; no wildcard) | +| `evidence_kind` | **`load-bearing`** (§9.4 — a control's soundness depends on the revocation event being present) | +| `completeness_trade` | none, and none is permitted — §9.6 requires emission atomicity of a load-bearing source | +| `may_write` | true | +| `may_read` | false — a source does not gain a read surface by emitting | +| `secret_policy` | `redact` (estate default; see below) | +| `tenants` | `["*"]` — **proposed, needs approval-engine's confirmation** | +| Retention | no expiry set; recoverable history is the platform `data.backup` window, 30 days, `measured` | +| Custody class | `operational` — never `archive`, never WORM | + +### Event classes + +The four §9.4 classes, carried as distinct actions on one source: + +| Class | Why it matters | +| --- | --- | +| issuance | the approval came into being | +| use | single consumption, a state change owned by `approval-engine` | +| supersession | the prior approval stopped being current | +| revocation | **the acute one** — the most valuable event to suppress | + +Audit Core stores all four and derives nothing from them. It renders no verdict +on whether an approval is still valid (§9.4), asserted by test in +`tests/test_approval_validity_prohibition.py`. + +### The `tenants` value is the one input still owed + +`["*"]` is proposed because approval issuance is not obviously tenant- +partitioned, and the alternative — an explicit tenant list — cannot be invented +here. `audit_core/senders.py` states that omitting a tenant restriction "should +be justified per sender, not adopted by default", so this needs +`approval-engine` to either confirm the wildcard with its reasoning or supply +the list. Tenant identifiers are opaque to Audit Core; their shape is owned by +the IAM Profile and `tenant-engine`. + +### `secret_policy` + +`redact` accepts the event and redacts a secret-shaped field, so a legitimate +event is not lost over one field. For a load-bearing source that trade is worth +re-examining: `reject` refuses the event instead, which for approvals converts a +redactable field into a delivery failure the source must retry. `redact` is the +recommendation — losing a revocation record is worse than storing it with one +field redacted — but it is `approval-engine`'s call to make explicitly rather +than inherit. + +## Applied in this repository + +| Input | Where | +| --- | --- | +| Non-secret scope | `deploy/senders-scope.json` + `deploy/senders-scope.yaml` (lockstep) | +| Receiver ingress | `deploy/networkpolicies.yaml`, `audit-core-approval-engine-ingress` | +| Evidence kind in the model | `audit_core/senders.py`, `SenderIdentity.evidence_kind` | + +The scope overlay only applies to a sender the Secret already carries, so this +entry is **inert until the token exists**. That is what makes it safe to land +ahead of the credential rather than in the same change. + +The ingress rule is narrower than `user-engine`'s: namespace **and** pod label +in a single `from` peer, so both are ANDed. A new sender should not inherit an +older rule's breadth. `user-engine`'s sender and its policy are unchanged. + +## Still owed by others + +| Input | Owner | Note | +| --- | --- | --- | +| Token at `approval-engine/approval-engine-audit`, key `audit-token` | OpenBao (`railiance-platform`), routed via `warden route` | Audit Core never holds or transports it | +| `tenants` confirmation | `approval-engine` | see above | +| `secret_policy` confirmation | `approval-engine` | see above | +| Applying the manifests to `railiance01` | operator | Audit Core does not apply cluster changes unprompted | + +## What Audit Core will and will not claim about this stream + +Stated here because §9.6 requires the bound to travel with the trail, and +because a load-bearing source will otherwise be told less than it needs. + +**Will:** every event it accepted is stored append-only, and the hash chain +detects alteration or truncation of what it received. + +**Will not:** that the archive proves an approval event occurred, or that the +absence of one proves it did not. Completeness at the boundary is +`approval-engine`'s obligation and an archive cannot retrofit it. + +### Open gates, and which of them block what + +| Gate | Blocks admission? | What it actually bounds | +| --- | --- | --- | +| `T02` attestation scheduling | **No** | Evidence *quality*, not custody. See below. | +| `T04` heartbeat / missing-heartbeat findings | No | Detection of adversarial omission for rare classes — the one that matters most for revocation | +| `T06` reconciliation counts | No | The source's own ability to detect divergence | +| `T05` declared cadence | No | Held deliberately on the §17 Taxonomy schema | + +**T02 is an evidence-quality gate, not an admission or deployment blocker.** +Admission depends on identity, scope, ingress and a token. Attestation freshness +governs what Audit Core may *claim*, not whether it accepts and durably stores. +The append-only trigger and hash chain operate regardless; what a stale +attestation removes is detection of a suffix rewrite by someone who owns the +database. Since `AUDIT-WP-0009-T01`, `/readyz` reports `tamper_evidence: false` +rather than overclaiming, and will keep doing so until T02 mounts a renewed +attestation. + +It also is not the gate that matters most here. §9.6 is explicit that the acute +risk for approvals is **omission**, and tamper evidence does not address +omission at all — T04 does. So a consumer waiting on T02 before trusting +approval evidence would be waiting on the wrong control. Until T04 and T06 +land, Audit Core cannot detect a suppressed revocation, and no reading of the +chain, attested or not, changes that. diff --git a/docs/senders.example.json b/docs/senders.example.json index fd9e4ab..7c91ba1 100644 --- a/docs/senders.example.json +++ b/docs/senders.example.json @@ -1,30 +1,67 @@ [ { "name": "user-engine", - "tokens": ["replace-with-write-token"], - "sources": ["user-engine"], - "tenants": ["*"], + "tokens": [ + "replace-with-write-token" + ], + "sources": [ + "user-engine" + ], + "tenants": [ + "*" + ], "may_write": true, "may_read": false, - "secret_policy": "redact" + "secret_policy": "redact", + "evidence_kind": "attributive", + "completeness_trade": "illustrative: emission is after commit, so a crash between the two loses the event" }, { "name": "operator", - "tokens": ["replace-with-read-token"], - "sources": ["user-engine"], - "tenants": ["*"], + "tokens": [ + "replace-with-read-token" + ], + "sources": [ + "user-engine" + ], + "tenants": [ + "*" + ], "may_write": true, "may_read": true, "secret_policy": "redact" }, { "name": "temporary-evidence-sender", - "tokens": ["replace-with-short-lived-token"], - "sources": ["whitehat-security"], - "tenants": ["tenant:trial:named-fixture"], + "tokens": [ + "replace-with-short-lived-token" + ], + "sources": [ + "whitehat-security" + ], + "tenants": [ + "tenant:trial:named-fixture" + ], "may_write": true, "may_read": true, "secret_policy": "redact", - "expires_at": "2026-08-22T18:15:00Z" + "expires_at": "2026-08-22T18:15:00Z", + "evidence_kind": "attributive" + }, + { + "name": "approval-engine", + "tokens": [ + "replace-with-write-token" + ], + "sources": [ + "approval-engine" + ], + "tenants": [ + "*" + ], + "may_write": true, + "may_read": false, + "secret_policy": "redact", + "evidence_kind": "load-bearing" } ] diff --git a/tests/test_networkpolicies.py b/tests/test_networkpolicies.py index cc192de..cb24bf0 100644 --- a/tests/test_networkpolicies.py +++ b/tests/test_networkpolicies.py @@ -24,3 +24,38 @@ def test_whitehat_ingress_is_bound_to_namespace_and_target_labels(): assert expected_peer in policy assert policy.count(" - namespaceSelector:") == 1 assert " - {protocol: TCP, port: 8080}" in policy + + +def test_approval_engine_ingress_is_bound_to_namespace_and_pod_labels(): + """AUDIT-WP-0009-T09. A load-bearing source gets a narrow rule, not a wide one.""" + documents = (ROOT / "deploy" / "networkpolicies.yaml").read_text().split("\n---\n") + policy = next( + document + for document in documents + if "name: audit-core-approval-engine-ingress" in document + ) + + # One `from` peer holding both selectors. Two list items would be OR, and + # would admit every pod in the approval-engine namespace plus every pod + # anywhere carrying the app label. + expected_peer = """ - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: approval-engine + podSelector: + matchLabels: + app.kubernetes.io/name: approval-engine""" + assert expected_peer in policy + assert policy.count(" - namespaceSelector:") == 1 + assert " - {protocol: TCP, port: 8080}" in policy + + +def test_user_engine_sender_ingress_is_unchanged_by_the_new_sender(): + """glas-harness asked for user-engine's sender to stay as it is.""" + documents = (ROOT / "deploy" / "networkpolicies.yaml").read_text().split("\n---\n") + policy = next( + document + for document in documents + if "name: audit-core-sender-ingress" in document + ) + assert "kubernetes.io/metadata.name: user-engine" in policy + assert "approval-engine" not in policy diff --git a/tests/test_senders.py b/tests/test_senders.py index 6a5d93b..4d74cd1 100644 --- a/tests/test_senders.py +++ b/tests/test_senders.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from audit_core.senders import SenderRegistry, WILDCARD +from audit_core.senders import SenderIdentity, SenderRegistry, WILDCARD ROOT = Path(__file__).resolve().parents[1] SCOPE_FILE = ROOT / "deploy" / "senders-scope.json" @@ -158,3 +158,164 @@ def test_invalid_scope_overlay_is_a_startup_error(): SenderRegistry.from_env( {"AUDIT_CORE_SENDERS": secret, "AUDIT_CORE_SENDERS_SCOPE": "{}"} ) + + +# --- AUDIT-WP-0009-T03: §9.6 evidence kind --------------------------------- + + +def test_evidence_kind_defaults_to_attributive(): + """A source that has not declared must not be treated as load-bearing. + + The other default would have audit-core imply a completeness obligation no + source ever accepted. + """ + identity = SenderIdentity( + name="quiet", tokens=("t",), sources=frozenset({"quiet"}) + ) + assert identity.evidence_kind == "attributive" + assert identity.is_load_bearing is False + + +def test_load_bearing_is_an_explicit_declaration(): + identity = SenderIdentity( + name="approval-engine", + tokens=("t",), + sources=frozenset({"approval-engine"}), + evidence_kind="load-bearing", + ) + assert identity.is_load_bearing is True + + +def test_unknown_evidence_kind_is_refused(): + with pytest.raises(ValueError, match="evidence_kind must be one of"): + SenderIdentity( + name="x", tokens=("t",), sources=frozenset({"x"}), evidence_kind="best-effort" + ) + + +def test_attributive_source_carries_its_declared_trade(): + identity = SenderIdentity( + name="tenant-engine", + tokens=("t",), + sources=frozenset({"tenant-engine"}), + completeness_trade="drain is non-blocking; emission is after commit", + ) + declaration = identity.evidence_declaration() + assert declaration["completeness_trade"].startswith("drain is non-blocking") + # Neither kind licenses a completeness claim (§9.6). + assert declaration["completeness_claimed"] is False + + +def test_load_bearing_source_may_not_declare_a_trade(): + """§9.6 requires atomicity of it, so there is no trade to record.""" + with pytest.raises(ValueError, match="may not declare a completeness_trade"): + SenderIdentity( + name="approval-engine", + tokens=("t",), + sources=frozenset({"approval-engine"}), + evidence_kind="load-bearing", + completeness_trade="emits after commit", + ) + + +def test_blank_trade_is_refused_rather_than_stored(): + with pytest.raises(ValueError, match="completeness_trade must say what"): + SenderIdentity( + name="x", tokens=("t",), sources=frozenset({"x"}), completeness_trade=" " + ) + + +def test_registration_schema_carries_evidence_kind(): + registry = SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps([ + { + "name": "approval-engine", + "tokens": ["tok"], + "sources": ["approval-engine"], + "tenants": ["*"], + "evidence_kind": "load-bearing", + }, + { + "name": "tenant-engine", + "tokens": ["tok2"], + "sources": ["tenant-engine"], + "tenants": ["*"], + "completeness_trade": "non-blocking drain", + }, + ]) + }) + by_name = {i.name: i for i in registry.identities} + assert by_name["approval-engine"].is_load_bearing is True + assert by_name["tenant-engine"].is_load_bearing is False + assert by_name["tenant-engine"].completeness_trade == "non-blocking drain" + + +def test_scope_overlay_may_raise_the_evidence_kind(): + registry = SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps( + [{"name": "approval-engine", "tokens": ["tok"], "sources": ["approval-engine"]}] + ), + "AUDIT_CORE_SENDERS_SCOPE": json.dumps( + [{"name": "approval-engine", "evidence_kind": "load-bearing"}] + ), + }) + assert registry.identities[0].is_load_bearing is True + + +def test_scope_overlay_may_not_downgrade_a_load_bearing_source(): + """A ConfigMap refresh must not quietly drop atomicity obligations.""" + with pytest.raises(ValueError, match="may not downgrade"): + SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps([{ + "name": "approval-engine", + "tokens": ["tok"], + "sources": ["approval-engine"], + "evidence_kind": "load-bearing", + }]), + "AUDIT_CORE_SENDERS_SCOPE": json.dumps( + [{"name": "approval-engine", "evidence_kind": "attributive"}] + ), + }) + + +# --- AUDIT-WP-0009-T09: approval-engine registration inputs ---------------- + +SCOPE_CONFIGMAP = ROOT / "deploy" / "senders-scope.yaml" + + +def test_declared_scope_and_configmap_stay_in_lockstep(): + """The header says to keep them in lockstep; nothing asserted it.""" + yaml = pytest.importorskip("yaml") + embedded = yaml.safe_load(SCOPE_CONFIGMAP.read_text())["data"]["senders-scope.json"] + assert json.loads(embedded) == json.loads(SCOPE_FILE.read_text()) + + +def test_approval_engine_is_declared_load_bearing(): + scope = json.loads(SCOPE_FILE.read_text()) + entry = next(e for e in scope if e["name"] == "approval-engine") + assert entry["evidence_kind"] == "load-bearing" + assert entry["sources"] == ["approval-engine"] + # A source does not gain a read surface by emitting. + assert entry["may_read"] is False + # §9.6 permits no completeness trade for a load-bearing source. + assert "completeness_trade" not in entry + assert "tokens" not in entry and "token" not in entry + + +def test_a_scope_entry_without_a_token_admits_nothing(): + """What makes it safe to land approval-engine's scope ahead of its credential.""" + registry = SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps( + [{"name": "user-engine", "tokens": ["live"], "sources": ["user-engine"]}] + ), + "AUDIT_CORE_SENDERS_SCOPE_PATH": str(SCOPE_FILE), + }) + assert [i.name for i in registry.identities] == ["user-engine"] + assert registry.authenticate("Bearer live").name == "user-engine" + + +def test_user_engine_evidence_kind_is_not_asserted_on_its_behalf(): + """Undeclared means attributive by default, not a claim audit-core made.""" + scope = json.loads(SCOPE_FILE.read_text()) + entry = next(e for e in scope if e["name"] == "user-engine") + assert "evidence_kind" not in entry diff --git a/workplans/AUDIT-WP-0009-evidence-role-conformance.md b/workplans/AUDIT-WP-0009-evidence-role-conformance.md index 7e50365..98bd114 100644 --- a/workplans/AUDIT-WP-0009-evidence-role-conformance.md +++ b/workplans/AUDIT-WP-0009-evidence-role-conformance.md @@ -102,7 +102,7 @@ and proves nothing. Record the cadence in `docs/integrity.md`. ```task id: AUDIT-WP-0009-T03 -status: todo +status: done priority: high state_hub_task_id: "acae6085-ada7-51b2-8dc0-4abac7f7e7b3" ``` @@ -114,6 +114,23 @@ deliberately traded away atomicity, carry the declared trade with it, because §9.6 requires the trade be declared where the trail is documented. Prerequisite for T04–T06. +Done 2026-09-06. `evidence_kind` and `completeness_trade` on `SenderIdentity`, +the `AUDIT_CORE_SENDERS` schema, and the non-secret scope overlay. Two +asymmetries are deliberate and asserted: + +- **Default is `attributive`.** The other default would have audit-core imply a + completeness obligation no source ever accepted. +- **The overlay may raise the kind, never lower it.** Same principle that stops + an ExternalSecret refresh shrinking `user-engine`'s tenants: a ConfigMap + refresh must not be able to drop a source's atomicity and detection + obligations without anyone deciding to. Downgrading raises. + +A load-bearing source may not carry a `completeness_trade` — §9.6 requires +atomicity of it, so recording a trade would store a contradiction as policy. +`evidence_declaration()` states `completeness_claimed: False` for **both** +kinds, because neither licenses the claim that the archive proves an event +occurred. Twelve tests in `tests/test_senders.py`. + ```task id: AUDIT-WP-0009-T04 status: todo @@ -181,7 +198,7 @@ is worth asserting in `tests/`. ```task id: AUDIT-WP-0009-T09 -status: todo +status: progress priority: high state_hub_task_id: "fd4a4ac3-e525-57c1-9179-e0fcd0226913" ``` @@ -213,6 +230,40 @@ Token provisioning goes by the approved custody path (`approval-engine/approval-engine-audit`, key `audit-token`) via `warden route`; no sender token passes through Git, State Hub, or a workplan. +**In progress 2026-09-06.** T03 landed, so the registration inputs are now +expressible. Prepared, non-secret, and recorded in +`docs/approval-engine-source-registration.md`: + +- Non-secret scope in `deploy/senders-scope.json` and `senders-scope.yaml`, + `evidence_kind: load-bearing`, `may_read: false`, exact source match. Landed + ahead of the credential deliberately: the overlay only applies to a sender + the Secret already carries, so the entry admits nothing until the token + exists. A test asserts that inertness rather than trusting the reading. +- `audit-core-approval-engine-ingress` in `deploy/networkpolicies.yaml`: + namespace **and** pod label in one `from` peer, so both are ANDed. Narrower + than `user-engine`'s namespace-only rule on purpose — a new sender should not + inherit an older rule's breadth. `user-engine`'s policy is unchanged, and a + test asserts it stays that way. +- The four §9.4 classes, retention (no expiry; 30-day `measured` recoverable + window), custody class, and the claim bound. + +**Two inputs are owed by `approval-engine`, not by audit-core.** `tenants` is +proposed as `["*"]` and needs confirmation with reasoning or a list — +`senders.py` says a missing tenant restriction must be justified per sender, +and audit-core cannot justify it on another repo's behalf. `secret_policy` is +recommended `redact` (losing a revocation record is worse than storing it with +one field redacted) but should be chosen explicitly rather than inherited. +Applying the manifests is an operator action audit-core does not take +unprompted. + +**T02 is not a gate on this.** Asked directly by `glas-harness`: attestation +freshness governs what audit-core may *claim*, not whether it accepts and +durably stores. The append-only trigger and hash chain operate regardless. And +it is not even the relevant control — §9.6 is explicit that the acute risk for +approvals is omission, which tamper evidence does not address at all. T04 does. +A consumer waiting on T02 before trusting approval evidence would be waiting on +the wrong thing. + ```task id: AUDIT-WP-0009-T10 status: todo