Developer Hub › Audit Events & the Event Type Registry

Audit Events & the Event Type Registry

How your application emits its own audited events — registering the event types it owns, naming them so they stay queryable in five years, and confirming they actually landed.

Read this before your first POST /api/audit/append. The event vocabulary is closed: an event_type the platform has never heard of is rejected before anything is written. That rejection is silent in practice — the emit path is non-throwing by design, so an app that never registers its types reports every governed action as recorded while its audit chain stays empty. One vertical shipped 32 event names, none of which could ever be appended, and found out only by going to look at why the ledger was empty.

Why the vocabulary is closed

Producers reject any event_type that is not registered. This is a deliberate constraint (OC-2), not an oversight, and it is worth understanding before you try to work around it.

An open vocabulary decays within a single release. One team writes lead.routed, another lead.route, a third routing.applied — all three land in the ledger, all three are queryable, and nobody can answer "how often was a lead routed?" without knowing the history of who wrote which. An audit ledger that cannot answer that question is a log file with extra steps.

So the rule stays. What changed on 3 August 2026 is that you now have a supported way in: a tenant-scoped registration endpoint. Before that the registry was a compile-time constant in the platform's own source, and the only way to add a type was a pull request against the platform plus a deploy — which coupled every one of your releases to one of ours.

The rules, in order of what they will cost you

1 · The name must be <domain>.<entity>.<verb>.v<N>

Lowercase; - or _ allowed inside a segment; at least two segments before the version. All 294 platform types follow it, and registration rejects anything that does not.

AcceptedRejectedWhy
capture.lead.created.v1capture.createdno version suffix
billing.invoice.finalized.v1Capture.Lead.Created.v1uppercase
ai_gateway.tenant_credential.bound.v1capture.v1only one segment before the version
tenant.role-template.updated.v1capture lead created.v1whitespace
The .v<N> suffix is the whole point, not decoration. It is what lets the payload shape change later without breaking historical queries: when your capture payload gains a required field, you register capture.lead.created.v2 and it ships alongside v1. Rows already written under the old shape keep meaning what they meant. A ledger whose types can be redefined in place cannot answer a question about the past — which is the only kind of question an audit ledger is for.

2 · Register before you append

Once per type, typically from a boot-time provisioner alongside your consent-purpose and role provisioning. Registration is additive: a repeat returns 200 with the stored metadata and created: false, so re-running it on every deploy is both safe and the intended usage.

3 · Your events are yours; platform events are not

Do not file a business event under a platform name (vault.*, tenant.*, audit.*). It is worse than having no entry at all: the row exists, is queryable, and means something else entirely. Registration refuses a baseline name outright, and resolution reads the platform baseline first, so a tenant registration can never shadow a platform type. Nor can another tenant see yours, or you theirs.

4 · A 2xx is not proof the event landed

emitEvent catches and logs rather than propagating, so an audit outage never blocks the caller's hot path. That is correct — but it means a permanent contract rejection looks exactly like a transient blip. If you need certainty, query audit.entry.

An empty chain verifies clean. POST /api/audit/verify returning ok: true proves nothing if nothing was ever appended. Assert on entries_checked, not on ok alone — a green verification over an empty chain is the exact shape this whole failure took.

5 · retention_class is load-bearing

When an append omits it, the registered type's class applies. Declaring operational on something regulated shreds it at 90 days instead of seven years — quietly, and years after the decision.

The calls

CallWhoPurpose
POST /api/events/typestenant JWTRegister one event type for your tenant. 201 first time, 200 on a repeat.
GET /api/events/typestenant JWTThe platform baseline plus your own, with platform_count / tenant_count.
GET /api/events/types/{type}tenant JWTOne type, with source: "platform" | "tenant".
POST /api/audit/appendtenant JWTAppend a hash-chained entry. Rejects an unresolvable type before any write.
POST /api/audit/verifytenant JWTWalk the chain for a pool and prove it is unbroken.

Registration payload

POST /api/events/types            Authorization: Bearer <tenant JWT>
{
  "event_type":        "capture.lead.created.v1",
  "retention_class":   "regulated",        // transient | operational | regulated
  "conflict_policy":   "event-sourcing",   // crdt | lww | merge | event-sourcing | human-review
  "schema_state":      "active",           // optional — default active
  "compaction_policy": "none",             // optional — default none
  "schema_version":    1                   // optional — default 1
}

The tenant comes from your verified token, never from the body — a user of one tenant cannot define vocabulary inside another.

Worked example — a vertical provisioning its vocabulary at boot

# 1 · register each type your app owns. Idempotent: run it on every deploy.
for t in capture.lead.created.v1 capture.lead.promoted.v1 capture.lead.rejected.v1; do
  curl -sS -X POST "$GW/api/events/types" \
    -H "Authorization: Bearer $TENANT_JWT" -H 'Content-Type: application/json' \
    -d "{\"event_type\":\"$t\",\"retention_class\":\"regulated\",
         \"conflict_policy\":\"event-sourcing\"}"
done
# -> 201 {"data":{"event_type":"capture.lead.created.v1", ... ,"created":true,"source":"tenant"}}
# -> 200 with "created":false on the next deploy. Not an error.

# 2 · now the append your app was always making succeeds
curl -sS -X POST "$GW/api/audit/append" \
  -H "Authorization: Bearer $TENANT_JWT" -H 'Content-Type: application/json' \
  -d '{"pool_index":"'"$POOL"'","event_type":"capture.lead.created.v1",
       "actor_kind":"human","payload":{"lead_id":"L-1"}}'
# -> 201 {"data":{"entry_id":"...","seq":1,"entry_hash":"...","retention_class":"regulated"}}
#    retention_class came from the REGISTERED type — the append never mentioned it.

# 3 · prove it landed. entries_checked is the number that carries information.
curl -sS -X POST "$GW/api/audit/verify" \
  -H "Authorization: Bearer $TENANT_JWT" -H 'Content-Type: application/json' \
  -d '{"pool_index":"'"$POOL"'"}'
# -> {"data":{"ok":true,"entries_checked":1, ...}}   ok:true with entries_checked:0 means
#    you have verified an empty chain and learned nothing.

Errors you will actually hit

Status / codeWhat it meansFix
400 UnregisteredEventType
on /api/audit/append
The type is in neither the platform baseline nor your tenant's registered types.Register it first. If you believe you did, check the name character-for-character and that you registered it for this tenant.
400 ValidationError
"does not follow the naming convention"
Almost always a missing .v1.Rename. This is rejected at registration precisely so you meet it while you can still fix it, rather than in production.
400 ValidationError
"is a platform baseline type"
You tried to register a name the platform already owns.Use it as-is — it is already emittable — or pick a name in your own domain.
400 ValidationError
"a tenant-scoped token is required"
Your token carries no tenant_id claim.Log in with a tenant_id, or exchange an application credential scoped to the tenant.
200 with created: falseNot an error. The type was already registered and was not overwritten.Nothing. If the returned metadata differs from what you sent, the stored version wins — registration is additive by design.
Why re-registration cannot overwrite. Silently rewriting a live type's retention_class would change the regulatory meaning of every entry already written under it, and the ledger cannot be re-derived. The database enforces this too: UPDATE on the registry table is blocked by trigger, not merely discouraged in the service. To change a payload shape, register the next version.

See also: Consumption contract §2.1 — what an application may and may not extend locally · AGENTS.md — the same rules in the form an AI coding agent loads first · Authentication & Identity — getting the tenant JWT these calls need.