diff --git a/deploy/networkpolicies.yaml b/deploy/networkpolicies.yaml index a618180..57f3f0a 100644 --- a/deploy/networkpolicies.yaml +++ b/deploy/networkpolicies.yaml @@ -37,6 +37,32 @@ spec: --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy +metadata: + name: audit-core-tenant-engine-ingress + namespace: audit-core +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: audit-core + policyTypes: [Ingress] + ingress: + # AUDIT-WP-0010-T03 / AUDIT-IN-0002. Attributive mutation evidence. + # Both selectors belong to one peer and are therefore ANDed. Attributive + # rather than load-bearing changes what may be claimed of the stream, not + # how narrow its reachability should be — a weaker evidence class is not a + # reason for a wider network rule. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: tenant-engine + podSelector: + matchLabels: + app.kubernetes.io/name: tenant-engine + ports: + - {protocol: TCP, port: 8080} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy metadata: name: audit-core-whitehat-ingress namespace: audit-core diff --git a/deploy/senders-scope.json b/deploy/senders-scope.json index c904a74..94f1b53 100644 --- a/deploy/senders-scope.json +++ b/deploy/senders-scope.json @@ -10,6 +10,19 @@ "may_write": true, "may_read": false }, + { + "name": "tenant-engine", + "sources": [ + "tenant-engine" + ], + "tenants": [ + "*" + ], + "may_write": true, + "may_read": false, + "evidence_kind": "attributive", + "completeness_trade": "outbox drains after commit, not inside the mutation transaction; declared at tenant-engine/docs/evidence-emission.md" + }, { "name": "approval-engine", "sources": [ diff --git a/deploy/senders-scope.yaml b/deploy/senders-scope.yaml index 74946e0..872ac1e 100644 --- a/deploy/senders-scope.yaml +++ b/deploy/senders-scope.yaml @@ -32,6 +32,19 @@ data: "may_write": true, "may_read": false }, + { + "name": "tenant-engine", + "sources": [ + "tenant-engine" + ], + "tenants": [ + "*" + ], + "may_write": true, + "may_read": false, + "evidence_kind": "attributive", + "completeness_trade": "outbox drains after commit, not inside the mutation transaction; declared at tenant-engine/docs/evidence-emission.md" + }, { "name": "approval-engine", "sources": [ diff --git a/docs/event-envelope.md b/docs/event-envelope.md new file mode 100644 index 0000000..d824df4 --- /dev/null +++ b/docs/event-envelope.md @@ -0,0 +1,110 @@ +# The ingest envelope: what `POST /v1/events` accepts + +`AUDIT-WP-0010-T04`. Written because it was not written down, and a sender +built against a shape the receiver does not implement. + +This is the wire contract for a sender. It is the authority; anything a sender +infers from another sender's code, from `docs/audit-backend-contract.md` (which +describes the *storage* record, not the request), or from a schema name, is a +guess. + +## Request + +``` +POST /v1/events +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +## Body — all eight fields are required + +| Field | Type | Meaning | +| --- | --- | --- | +| `id` | string | The event id. Must equal the `Idempotency-Key` header exactly, or the request is rejected `idempotency_key_mismatch` | +| `type` | string | The domain event type. Stored as the record's `action` | +| `source` | string | The emitting system. Checked against the credential's permitted sources | +| `subject` | string | What the event is about. Stored as the record's `resource` | +| `tenant` | string | The affected tenant. Checked against the credential's permitted tenants by **exact string equality** | +| `correlation_id` | string | Ties an event to the request or operation that caused it | +| `occurred_at` | string | RFC 3339 **with an explicit offset**. A naive timestamp is rejected — it is ambiguous by up to a day | +| `data` | object | The event payload. Stored verbatim under `details.data`, subject to the sender's `secret_policy` | + +Every one of the eight must be present **and truthy**. An empty string, an +empty object, or `null` fails the same way a missing key does. + +There is no `schema_version` field. A body carrying one is not rejected for it — +unknown top-level keys are ignored — but it is not stored and it selects +nothing. Do not treat a version string as a contract with this receiver. + +### Minimal accepted body + +```json +{ + "id": "a5f3...", + "type": "role.granted", + "source": "tenant-engine", + "subject": "tenant:acme", + "tenant": "tenant:acme", + "correlation_id": "req-9f21", + "occurred_at": "2026-09-10T11:04:12+00:00", + "data": {"role": "admin", "granted_by": "u-1"} +} +``` + +## What the receiver derives, and a sender must not send + +These are set by audit-core and any value a sender supplies for them is +ignored. Sending them is harmless but misleading, because it looks like the +sender controls them. + +| Stored field | Where it comes from | +| --- | --- | +| `observed_at` | normalized from `occurred_at` | +| `action` | `type` | +| `resource` | `subject` | +| `scope` | always `"tenant"` | +| `outcome` | always `"recorded"` — this receiver records; it does not adjudicate | +| `actor` | always `null` at ingest. If the acting principal matters, put it inside `data` | +| `details.redaction` | added when `secret_policy` removed something | + +`outcome` and `actor` being fixed is deliberate and worth understanding: an +audit event here is a record that a sender said something, not a finding about +it. A sender that wants an outcome recorded puts it in `data`, where it reads +as the sender's claim rather than the archive's. + +## Responses + +| Status | Meaning | +| --- | --- | +| `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` | +| `401` | Unknown or expired token | +| `403` | The credential lacks `may_write` | + +**A 400 means the event is not in the archive.** It is retained in the +dead-letter surface with its reason, which is a diagnostic queue, not custody: +it is not chained, and it is not what a reviewer reads. A sender whose drain +treats 400 as terminal will lose the event on both sides — its outbox marks the +row handled and audit-core holds only a dead letter. Treat a 400 as an +integration defect to fix, never as a delivery outcome to record and move on +from. + +## Rejection is total, not partial + +There is no lossy accept. If a required field is missing the whole event is +rejected — audit-core does not store a partial record with a synthesized +`correlation_id` or a defaulted timestamp, because a record the archive +partly invented is worse than no record: a reviewer cannot tell which fields +the sender actually asserted. + +This is why an envelope mismatch is loud rather than silent, and it is the +intended behaviour. + +## Before you write a drain + +Post one event against a non-production receiver and check for `202`. Every +field above is checked on the first request, so a single successful post +validates the whole envelope. A sender that first exercises the path in +production discovers the contract through its dead-letter queue. diff --git a/docs/tenant-engine-source-registration.md b/docs/tenant-engine-source-registration.md new file mode 100644 index 0000000..7480eb1 --- /dev/null +++ b/docs/tenant-engine-source-registration.md @@ -0,0 +1,151 @@ +# tenant-engine source registration + +`AUDIT-WP-0010` · intake `AUDIT-IN-0002` · statute §9.6 + +Registration inputs for `tenant-engine` as an attributive audit source. +Owner-side record, entirely non-secret. No sender token appears here, in Git, +in State Hub, or in a workplan. + +## Declaration + +| Field | Value | +| --- | --- | +| Sender name | `tenant-engine` | +| Permitted `source` | `tenant-engine` (exact; no wildcard) | +| `evidence_kind` | **`attributive`** — declared by the emitter, and the declaration is the emitter's to make | +| `completeness_trade` | **declared**: the outbox drains after commit rather than inside the mutation transaction (`tenant-engine/docs/evidence-emission.md`) | +| `may_write` | true | +| `may_read` | false | +| `secret_policy` | `redact` | +| `tenants` | `["*"]` — justified below, not inherited | +| Retention | no expiry set; recoverable history is the platform `data.backup` window, 30 days, `measured` | +| Custody class | `operational` | + +### Why the tenant scope is a wildcard, and why that is not a shrug + +`senders.py` requires a missing tenant restriction to be justified per sender, +so: `tenant-engine` is the tenant lifecycle engine. Its events carry the +*affected* `tenant_id`, and the set of affected tenants is every tenant that +exists, including ones created after this registration was written. An explicit +list would have to be edited on every tenant creation, and would fail closed at +exactly the moment a new tenant is provisioned — dead-lettering the creation +evidence of the tenant whose creation it is. That is a worse failure than the +breadth it buys. + +This is the same justification `user-engine` carries and a different one from +`approval-engine` and `informed-decision`, which are single-tenant by +construction and are therefore scoped exactly. + +The exposure the wildcard leaves is real and worth naming: a compromised +`tenant-engine` credential can attribute an event to any tenant. `source` is +still pinned exactly, so it cannot impersonate another engine. + +### The attributive trade, recorded here and not only in the emitter + +§9.6 permits attributive evidence to trade away emission atomicity **provided +the trade is declared and completeness is never claimed**. `tenant-engine` +declared it properly and in advance. Recorded on this side per the statute's +requirement that the trade travel with the trail: + +| Step | Atomic with the mutation? | +| --- | --- | +| local `events` row | yes | +| local `audit_outbox` row | yes | +| drain to audit-core | **no** — after commit | + +So a mutation can exist here without its event, and audit-core will never know. +Nothing in this archive detects that, and no reading of the chain changes it. +`completeness_claimed` is `false` for this source, as it is for every source. + +If a control ever starts branching on these events, the class must be raised to +load-bearing **before** that ships — and the scope overlay is deliberately +built so it can raise the kind but never lower it. + +## Blocking finding: the envelope does not match — events would be rejected + +**`tenant-engine` cannot deliver a single event today, and admitting the sender +would not change that.** This is the correction `AUDIT-IN-0002` invited rather +than a refusal. + +`tenant_engine.audit_core.envelope_for` emits a shape the receiver does not +accept. `normalize()` requires eight fields; the envelope supplies three of them +under the required names and omits one entirely. + +| Receiver requires | `tenant-engine` sends | Result | +| --- | --- | --- | +| `id` | `event_id` | missing | +| `type` | `action` | missing | +| `subject` | `resource` | missing | +| `occurred_at` | `observed_at` | missing | +| `data` | `details` | missing | +| `correlation_id` | — | **absent entirely** | +| `source` | `source` | ok | +| `tenant` | `tenant` | ok | + +Verified by running the real envelope through the real `normalize()`, not by +reading: it raises `invalid_event`. Every event would return **400** and be +dead-lettered. + +**Why this is worse than a normal integration bug.** `tenant-engine`'s drain +treats 400 as terminal (`400/409 dead-letter`, `docs/evidence-emission.md`). +So the outbox row is marked handled while audit-core holds only a dead letter — +which is not chained and is not custody. The event is lost on both sides, and +because the drain is non-blocking and attributive, nothing fails loudly. A +silent total loss of the stream, presenting as a working integration. + +`schema_version: audit-core.event.v1alpha1` selects nothing at this receiver. +There is no version negotiation; the name gave a false impression of a contract. + +**Whose fault this is: ours, mostly.** The accepted envelope was documented +nowhere a sender could read it. `docs/audit-backend-contract.md` describes the +stored record, and a sender reading it would reasonably infer the field names +that `tenant-engine` used. The contract is now published at +`docs/event-envelope.md`, and that gap is the actual root cause. + +**What is needed:** `tenant-engine` maps `envelope_for` onto the eight required +fields and supplies a `correlation_id` (it has a natural one — the request or +operation that caused the mutation). audit-core is **not** relaxing +`normalize()` to accept the alternate names: a receiver that guesses which key +means what would be inventing the mapping, and a record whose field meanings the +archive chose is not the sender's assertion any more. `correlation_id` in +particular cannot be synthesized — a made-up one is worse than none. + +A regression test (`tests/test_tenant_engine_envelope.py`) pins the mismatch, so +it becomes visible the moment the emitter is corrected rather than being +rediscovered in production. + +## 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-tenant-engine-ingress` | +| Declared trade | `SenderIdentity.completeness_trade`, carried through the overlay | +| Wire contract | `docs/event-envelope.md` | + +Inert until the token exists: the overlay applies only to a sender the Secret +already carries. + +The ingress rule ANDs namespace and pod label in one peer. Attributive rather +than load-bearing changes what may be claimed of the stream, not how narrow its +reachability should be — a weaker evidence class is not a reason for a wider +network rule. `user-engine`'s policy is unchanged. + +## Still owed + +| Input | Owner | Note | +| --- | --- | --- | +| Envelope correction | **`tenant-engine`** | Blocking. Nothing lands until this is fixed | +| Sender token, custody via `warden route` | OpenBao (`railiance-platform`) | audit-core never holds or transports it | +| Protected registry entry with explicit `secret_policy` | `railiance-platform` | The overlay does not override it | +| Applying the manifests | operator | audit-core does not apply cluster changes unprompted | +| Live positive-path evidence under `docs/evidence/` | audit-core, after the above | `AUDIT-WP-0010-T05` | + +## What Audit Core will and will not claim about this stream + +**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 a mutation occurred, or that absence +proves it did not — and here that bound is sharper than usual, because the +declared trade means a committed mutation can legitimately have no event at all. diff --git a/intakes/intakes.md b/intakes/intakes.md index a7a9751..b1364a0 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -100,7 +100,26 @@ description: > Requested: sender registration + token lane, or a correction if the envelope needs a field this engine is not sending. created: '2026-08-29' -updated: '2026-08-29' +updated: '2026-09-10' +outcome: registered-with-a-blocking-correction +resolution: 'Registered attributive with the declared completeness_trade + recorded receiver-side, tenants ["*"] justified per sender, source pinned + exact, ingress added — all inert pending the token + (docs/tenant-engine-source-registration.md, AUDIT-WP-0010 T01/T03/T04). + Taking the correction the intake invited: the envelope does not match. + envelope_for sends five required fields under other names (event_id, action, + resource, observed_at, details) and omits correlation_id entirely, so + normalize() raises invalid_event and every event would 400 and dead-letter. + Because the drain treats 400 as terminal, the outbox row is marked handled + while audit-core holds only a dead letter — lost on both sides, silently, + presenting as a working integration. normalize() is not being relaxed to + accept the alternate spellings: guessing which sender key means which stored + field would make the mapping audit-core''s rather than the sender''s, and + correlation_id cannot be synthesized at all. Root cause is audit-core''s — + the wire envelope was documented nowhere a sender could read it, and + schema_version audit-core.event.v1alpha1 selects nothing here. Contract now + published at docs/event-envelope.md. Stays OPEN until tenant-engine corrects + the emitter and T05 records live evidence.' state_hub_intake_id: "01a04d8f-b1c1-746a-8007-15221ebf0a48" ``` diff --git a/tests/test_networkpolicies.py b/tests/test_networkpolicies.py index 4cb6807..0850fab 100644 --- a/tests/test_networkpolicies.py +++ b/tests/test_networkpolicies.py @@ -97,3 +97,41 @@ def test_no_egress_to_informed_decision_is_created(): assert "approval-engine" not in egress # Postgres and DNS only. assert egress.count(" - namespaceSelector:") == 2 + + +def test_tenant_engine_ingress_is_bound_to_namespace_and_pod_labels(): + """AUDIT-WP-0010-T03. Attributive is a weaker claim, not a wider rule.""" + documents = (ROOT / "deploy" / "networkpolicies.yaml").read_text().split("\n---\n") + policy = next( + document + for document in documents + if "name: audit-core-tenant-engine-ingress" in document + ) + expected_peer = """ - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: tenant-engine + podSelector: + matchLabels: + app.kubernetes.io/name: tenant-engine""" + assert expected_peer in policy + assert policy.count(" - namespaceSelector:") == 1 + + +def test_every_sender_has_exactly_one_ingress_rule(): + """A fourth sender fails here rather than silently in production. + + AUDIT-WP-0010-T03 asked for this: the policy set and the registered sender + set must move together, and neither should be able to grow alone. + """ + import json + + text = (ROOT / "deploy" / "networkpolicies.yaml").read_text() + scope = json.loads((ROOT / "deploy" / "senders-scope.json").read_text()) + senders = {entry["name"] for entry in scope} + assert senders == { + "user-engine", "approval-engine", "informed-decision", "tenant-engine" + } + # user-engine's rule predates the per-sender naming convention. + assert "name: audit-core-sender-ingress" in text + for sender in senders - {"user-engine"}: + assert f"name: audit-core-{sender}-ingress" in text diff --git a/tests/test_senders.py b/tests/test_senders.py index 8bd4c77..8b745da 100644 --- a/tests/test_senders.py +++ b/tests/test_senders.py @@ -398,3 +398,55 @@ def test_a_load_bearing_source_cannot_be_given_a_completeness_trade(): evidence_kind="load-bearing", completeness_trade="emits after commit", ) + + +# --- AUDIT-WP-0010: tenant-engine admission -------------------------------- + + +def test_tenant_engine_is_attributive_with_its_trade_declared(): + scope = json.loads(SCOPE_FILE.read_text()) + entry = next(e for e in scope if e["name"] == "tenant-engine") + assert entry["evidence_kind"] == "attributive" + assert entry["sources"] == ["tenant-engine"] + assert entry["may_read"] is False + # §9.6 permits the trade only where it is declared. Recorded on the + # receiver side, not only in the emitter's documentation. + assert "after commit" in entry["completeness_trade"] + + +def test_tenant_engine_wildcard_tenant_does_not_widen_its_source(): + """The wildcard is on tenants only; source stays pinned.""" + registry = SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps([{ + "name": "tenant-engine", "tokens": ["fixture-only"], + "sources": ["*"], "tenants": ["*"], + }]), + "AUDIT_CORE_SENDERS_SCOPE_PATH": str(SCOPE_FILE), + }) + identity = registry.authenticate("Bearer fixture-only") + assert identity.permits_tenant("tenant:anything-created-later") + assert identity.permits_source("tenant-engine") + assert not identity.permits_source("user-engine") + assert not identity.permits_source("approval-engine") + assert identity.evidence_kind == "attributive" + + +def test_an_overlay_cannot_lower_a_load_bearing_source_to_attributive(): + """A ConfigMap refresh must not drop §9.6 obligations. Downgrading raises.""" + with pytest.raises(ValueError, match="downgrade|lower"): + SenderRegistry.from_env({ + "AUDIT_CORE_SENDERS": json.dumps([{ + "name": "informed-decision", "tokens": ["fixture-only"], + "sources": ["informed-decision"], "evidence_kind": "load-bearing", + }]), + "AUDIT_CORE_SENDERS_SCOPE_PATH": str(_write_downgrade_overlay()), + }) + + +def _write_downgrade_overlay(): + import tempfile + path = Path(tempfile.mkdtemp()) / "scope.json" + path.write_text(json.dumps( + [{"name": "informed-decision", "evidence_kind": "attributive"}] + )) + return path diff --git a/tests/test_tenant_engine_envelope.py b/tests/test_tenant_engine_envelope.py new file mode 100644 index 0000000..ff4c17b --- /dev/null +++ b/tests/test_tenant_engine_envelope.py @@ -0,0 +1,125 @@ +"""AUDIT-WP-0010-T04. The envelope tenant-engine actually sends. + +`AUDIT-IN-0002` invited a correction if the envelope needed a field the +emitter does not send. It does — six of eight required fields, one of them +absent entirely rather than merely renamed. + +These tests pin the mismatch rather than describing it, so that the day +tenant-engine corrects `envelope_for` the failure here is the signal, and so +that nobody quietly relaxes `normalize()` to accept the alternate spellings. +""" + +import pytest + +from audit_core.ingestion import normalize +from audit_core.senders import SenderIdentity + + +IDENTITY = SenderIdentity( + name="tenant-engine", + tokens=("fixture-only",), + sources=frozenset({"tenant-engine"}), + evidence_kind="attributive", + completeness_trade="outbox drains after commit", +) + +# Verbatim shape of tenant_engine.audit_core.envelope_for as of 2026-09-10. +TENANT_ENGINE_ENVELOPE = { + "schema_version": "audit-core.event.v1alpha1", + "event_id": "e-1", + "observed_at": "2026-09-10T00:00:00+00:00", + "tenant": "tenant:acme", + "scope": "tenant-engine", + "source": "tenant-engine", + "actor": "u-1", + "action": "role.granted", + "resource": "tenant:acme", + "outcome": "recorded", + "reason": None, + "details": {"role": "admin"}, +} + + +def test_the_tenant_engine_envelope_is_rejected_today(): + """Not a hypothetical: this is the shape on the wire, and it 400s.""" + with pytest.raises(ValueError, match="invalid_event"): + normalize(TENANT_ENGINE_ENVELOPE, "e-1", IDENTITY) + + +@pytest.mark.parametrize( + "required,sent_as", + [ + ("id", "event_id"), + ("type", "action"), + ("subject", "resource"), + ("occurred_at", "observed_at"), + ("data", "details"), + ], +) +def test_each_renamed_field_is_absent_under_the_name_the_receiver_reads(required, sent_as): + assert required not in TENANT_ENGINE_ENVELOPE + assert sent_as in TENANT_ENGINE_ENVELOPE + + +def test_correlation_id_is_absent_entirely_and_cannot_be_synthesized(): + """The one that is not a rename. + + A receiver-invented correlation_id would tie an event to an operation + audit-core never observed, which is worse than not having one. + """ + assert "correlation_id" not in TENANT_ENGINE_ENVELOPE + corrected = { + "id": TENANT_ENGINE_ENVELOPE["event_id"], + "type": TENANT_ENGINE_ENVELOPE["action"], + "source": TENANT_ENGINE_ENVELOPE["source"], + "subject": TENANT_ENGINE_ENVELOPE["resource"], + "tenant": TENANT_ENGINE_ENVELOPE["tenant"], + "occurred_at": TENANT_ENGINE_ENVELOPE["observed_at"], + "data": TENANT_ENGINE_ENVELOPE["details"], + } + with pytest.raises(ValueError, match="invalid_event"): + normalize(corrected, "e-1", IDENTITY) + + +def test_the_corrected_envelope_is_accepted(): + """What tenant-engine needs to send. The whole correction, in one place.""" + corrected = { + "id": "e-1", + "type": "role.granted", + "source": "tenant-engine", + "subject": "tenant:acme", + "tenant": "tenant:acme", + "correlation_id": "req-9f21", + "occurred_at": "2026-09-10T00:00:00+00:00", + "data": {"role": "admin", "actor": "u-1"}, + } + event = normalize(corrected, "e-1", IDENTITY) + assert event.event_id == "e-1" + assert event.action == "role.granted" + assert event.resource == "tenant:acme" + assert event.tenant == "tenant:acme" + assert event.details["data"]["role"] == "admin" + # Derived by the receiver, never taken from the sender. + assert event.outcome == "recorded" + assert event.actor is None + assert event.scope == "tenant" + + +def test_normalize_still_refuses_the_alternate_spellings(): + """Guards the decision not to relax the receiver. + + Accepting event_id/action/resource as aliases would make audit-core choose + which sender key means which stored field. The mapping belongs to the + sender, which is the party whose assertion the record is. + """ + required = ("id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data") + accepted = { + "id": "e-2", "type": "t", "source": "tenant-engine", "subject": "s", + "tenant": "tenant:acme", "correlation_id": "c", + "occurred_at": "2026-09-10T00:00:00+00:00", "data": {"k": "v"}, + } + for field in required: + broken = dict(accepted) + broken.pop(field) + with pytest.raises(ValueError, match="invalid_event"): + normalize(broken, "e-2", IDENTITY) diff --git a/workplans/AUDIT-WP-0010-tenant-engine-sender-admission.md b/workplans/AUDIT-WP-0010-tenant-engine-sender-admission.md index ed44dc8..0afdc93 100644 --- a/workplans/AUDIT-WP-0010-tenant-engine-sender-admission.md +++ b/workplans/AUDIT-WP-0010-tenant-engine-sender-admission.md @@ -4,7 +4,7 @@ type: workplan title: "Admit tenant-engine as an attributive sender" domain: infotech repo: audit-core -status: ready +status: active owner: claude topic_slug: railiance created: "2026-08-29" @@ -49,10 +49,20 @@ validated, not when events start arriving. ```task id: AUDIT-WP-0010-T01 -status: todo +status: done priority: high state_hub_task_id: "e26170e3-78f1-5d98-bb8f-46cef32244bb" ``` +Done 2026-09-10. `deploy/senders-scope.{json,yaml}`, recorded in +`docs/tenant-engine-source-registration.md`. `evidence_kind: attributive` with +the declared `completeness_trade` carried on the identity, so §9.6's +"declared where the trail is documented" is satisfied on the receiver side and +not only in the emitter. `tenants: ["*"]` is justified rather than inherited: +tenant-engine's events carry the *affected* tenant, the set is every tenant +including ones created later, and an explicit list would fail closed at exactly +the moment a tenant is provisioned — dead-lettering the creation evidence of +the tenant whose creation it is. `source` stays pinned exactly, asserted by +test. Inert until the token exists. Register the sender identity: name `tenant-engine`, `sources: [tenant-engine]`, `may_write: true`, `may_read: false`, `secret_policy: redact`, tenants scoped as the envelope requires. Follow the existing `user-engine` shape in @@ -72,10 +82,21 @@ transport a value through State Hub messages. ```task id: AUDIT-WP-0010-T03 -status: todo +status: done priority: high state_hub_task_id: "1a75fba2-a625-5e87-bba9-e38f010856fc" ``` +Done 2026-09-10. `audit-core-tenant-engine-ingress` in +`deploy/networkpolicies.yaml`, namespace AND pod label in one peer. Attributive +rather than load-bearing changes what may be claimed of the stream, not how +narrow its reachability should be — a weaker evidence class is not a reason for +a wider rule, so this follows approval-engine rather than user-engine's older +namespace-only breadth. + +The workplan asked that a future third sender fail the test rather than fail +silently in production. `test_every_sender_has_exactly_one_ingress_rule` does +that by deriving the expected set from `senders-scope.json`: the policy set and +the registered sender set can no longer grow independently. Extend the NetworkPolicy. `deploy/networkpolicies.yaml` currently admits `user-engine`; add `tenant-engine`. `tests/test_networkpolicies.py` exists — extend it so the second sender is asserted rather than assumed, and so a future @@ -83,10 +104,42 @@ third sender fails the test rather than silently failing in production. ```task id: AUDIT-WP-0010-T04 -status: todo +status: done priority: medium state_hub_task_id: "56bde19e-6b54-5d9e-a3d7-8b13e810cb51" ``` +Done 2026-09-10 — and it found the thing the task existed to find. + +**tenant-engine cannot deliver a single event today.** `envelope_for` emits a +shape `normalize()` rejects: five required fields are sent under other names +(`event_id`/`action`/`resource`/`observed_at`/`details` for +`id`/`type`/`subject`/`occurred_at`/`data`) and `correlation_id` is absent +entirely. Verified by running the real envelope through the real `normalize()`, +which raises `invalid_event`. Every event would 400 and dead-letter. + +Worse than an ordinary integration bug, because tenant-engine's drain treats +400 as terminal. The outbox row is marked handled while audit-core holds only a +dead letter — not chained, not custody. The event is lost on both sides, and +since the drain is non-blocking and attributive, nothing fails loudly: a silent +total loss of the stream presenting as a working integration. + +Taking the correction the intake invited rather than accepting a lossy record: +`normalize()` is **not** being relaxed to accept the alternate spellings. A +receiver that guesses which sender key means which stored field would be +inventing the mapping, and a record whose field meanings the archive chose is +no longer the sender's assertion. `correlation_id` especially cannot be +synthesized — an invented one ties an event to an operation audit-core never +observed. + +**Root cause is ours.** The accepted envelope was published nowhere a sender +could read it; `docs/audit-backend-contract.md` describes the stored record, and +a sender reading it would reasonably infer exactly the names tenant-engine used. +`schema_version: audit-core.event.v1alpha1` selects nothing here and gave a +false impression of a negotiated contract. The wire contract is now published: +`docs/event-envelope.md`. + +`tests/test_tenant_engine_envelope.py` pins the mismatch, the corrected +envelope, and the decision not to relax the receiver. Validate the envelope against what `tenant-engine` actually sends: `schema_version: audit-core.event.v1alpha1`, `tenant` = affected `tenant_id`, `action` = domain event type, `resource` = `tenant:`, duplicate event ids @@ -101,10 +154,14 @@ so the §9.6 trade is documented here and not only in the emitter. ```task id: AUDIT-WP-0010-T05 -status: todo +status: wait priority: medium state_hub_task_id: "777bb728-4a4d-5c5b-8b62-24fdb6ab31e3" ``` +**Waiting**, re-statused 2026-09-10. Blocked on the T04 envelope correction in +tenant-engine, then on the T02 token. There is nothing to prove end to end +until an event can be accepted at all; running this now would only reproduce +the dead-letter path already covered by test. Prove it end to end against the production receiver: a real event from `tenant-engine` accepted, attributed to the right tenant, redacted per policy, and linked into the chain. Record the evidence under `docs/evidence/`. Then