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
4.8 KiB
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
{
"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.