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