Events & the envelope
Every event in CNS — audit trail, activity log, or notification trigger — is the same envelope. There is no separate "notification event" type; notification intent is a per-event policy decision (see Policies) layered on top of one universal shape.
The envelope
Mirrors internal/core/event.Event and schemas/event.schema.json field-for-field.
"Client-suppliable" means a caller may set it on POST /v1/events; everything else is
always derived server-side, even if a client sends a value for it (extra/unknown
fields are rejected outright — the schema sets additionalProperties: false).
| Field | Client-suppliable? | Notes |
|---|---|---|
event_id | No | Server-minted ULID at ingestion. Doubles as the idempotency/dedup key. |
tenant_id | No | Derived from the authenticated principal. Never trusted from the client. |
event_type | Yes | Domain event name, e.g. invoice.paid. |
source.app / source.service / source.environment | No | Derived from the authenticated app/service credential. |
actor.subject_id | No (usually) | The end-user caller, unconditionally. An app-principal caller may assert it via actor_subject_id — see Apps & credentials. |
target.subject_ids[] | Yes | Who should be notified (fan-out). Platform validates every id belongs to the tenant. |
provenance | No | backend-verified | client-asserted — see Trust & render-tokens. |
delivery | No (rule-influenced) | instant | batched — the latency lane; set by policy, not by the caller. |
event_time | Yes | Client/source occur-time. Advisory (display) — never trusted for ordering. |
ingest_time | No | Server-stamped at ingestion. Trusted — the ordering anchor. |
session_id | Yes | Scopes seq. One per SDK-instance lifetime, or a propagated trace id. |
seq | Yes | Per-session monotonic sequence, assigned at emit-time. |
monotonic_offset_ms | Yes (optional) | performance.now()-derived offset for reliable intra-session interval math. |
data | Yes (optional) | Domain payload — whatever shape your event_type needs. |
metadata | Yes (optional) | Out-of-band context — e.g. a render-token for provenance escalation. |
Two orthogonal axes, not two kinds of event
- Persistence is universal. Every event lands in the chronological store — the activity/audit backbone. Nothing is "notification-only."
- Notification intent is per-event, decided by policy on
event_type(an explicitnotifyflag on the matching rule).
Two more axes ride along without changing the envelope shape:
- Emission mechanism — manual (your code calls
emit(), Phase 1) vs. auto (Phase-2 middleware calls the sameemit()with telemetry defaults) is about who callsemit(), not a different envelope. - Latency lane (
delivery: instant | batched) follows notification priority, not manual-vs-auto — an auto-emitted high-priority event can still beinstant.
actor vs. target
actor is who performed the action — it's the activity-timeline key ("who did
what, in order"). target is who should be notified — plural, because one action
can fan out to several recipients, each with their own delivery/read state. An
approval's actor is the approver; its target is the payee. A viewer acknowledging
their own notice is both — dedup is automatic when actor ∈ target.
Ordering: three clocks, never conflated
event_time— advisory, client/source-supplied, for display only.ingest_time— trusted, server-stamped, the real ordering anchor.seq— per-session monotonic, incremented at emit-time by the emitting SDK instance. This — not wall-clock time — is the ordering primitive within a session: zero coordination, immune to clock skew, safe under batching (a late batch still slots in correctly byseq/event_timeon read).
Cross-session timelines merge by session-start ingest_time, then seq — the exact
composite position the Activity API's opaque
cursor encodes. A gap or duplicate in a session's seq sequence is a provable
integrity signal (dropped or replayed events), not something CNS silently papers
over.
See it end to end
Quickstart walks through emitting one of these and reading it back; API Reference has the exact request/ response bodies.