111 lines
4.7 KiB
Markdown
111 lines
4.7 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` |
|
||
|
|
| `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.
|