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.
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.
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.
<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.
| Accepted | Rejected | Why |
|---|---|---|
capture.lead.created.v1 | capture.created | no version suffix |
billing.invoice.finalized.v1 | Capture.Lead.Created.v1 | uppercase |
ai_gateway.tenant_credential.bound.v1 | capture.v1 | only one segment before the version |
tenant.role-template.updated.v1 | capture lead created.v1 | whitespace |
.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.
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.
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.
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.
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.
retention_class is load-bearingWhen 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.
| Call | Who | Purpose |
|---|---|---|
POST /api/events/types | tenant JWT | Register one event type for your tenant. 201 first time, 200 on a repeat. |
GET /api/events/types | tenant JWT | The platform baseline plus your own, with platform_count / tenant_count. |
GET /api/events/types/{type} | tenant JWT | One type, with source: "platform" | "tenant". |
POST /api/audit/append | tenant JWT | Append a hash-chained entry. Rejects an unresolvable type before any write. |
POST /api/audit/verify | tenant JWT | Walk the chain for a pool and prove it is unbroken. |
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.
# 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.
| Status / code | What it means | Fix |
|---|---|---|
400 UnregisteredEventTypeon /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: false | Not 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. |
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.