audit-core/docs/event-envelope.md
tegwick 70e4044128
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
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
2026-10-03 21:18:13 +02:00

110 lines
4.8 KiB
Markdown

# 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 <sender token>
Idempotency-Key: <the event's id — must equal the body's "id">
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`, `content_refused` |
| `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.