Events & audit
Every mutation in a Substrat system emits a domain event. Events are the audit trail, the integration surface between engines, and the feed for reporting — one mechanism, three jobs.
What module code writes
ctx.emit({
type: 'workorder.completed', // module-namespaced
schemaVersion: 1,
entity: { entityType: 'workorder', entityId: order.id },
piiClass: 'none',
payload: { orderId: order.id, billable, total },
});Everything identifying the origin is deliberately absent from the input. The kernel stamps the envelope below the API surface:
type DomainEvent = {
id: EventId; // ULID — the idempotency key for consumers
type: string;
schemaVersion: number;
occurredAt: Instant; // stamped by kernel
tenantId: TenantId; // stamped by kernel
scopeId: ScopeId; // stamped by kernel
actor: PrincipalId | { system: ModuleId } | { connection: string }; // stamped from the stub's context
entity: EntityRef;
piiClass: 'none' | 'pseudonymous' | 'direct';
subjectId?: DataSubjectId;
authorization?: EventAuthorization[]; // K-34 — stamped kernel-side (see below)
impersonation?: { session: ImpersonationSessionId; by: PlatformActorId }; // K-42 — stamped kernel-side (see below)
payload: unknown;
};The actor is one of three things, and never a person who wasn't one: a PrincipalId, a { system: ModuleId } — a consumer running under a system actor, or a module's scheduled work firing on a timer — or a { connection: string } when a connector effected the write, an external provider's callback acting through a connection. A connector or a nightly job that read as a human in the audit trail would be worse than one that could not act at all, so each gets its own member rather than a synthetic principal.
authorization records, per mutation, which checks the emitting operation passed (K-34): each entry names a permission that was checked-and-passed, plus — when the allow resolved through a capability grant rather than a role bundle — a ref to the granting tuple (workorder:01J…, scope:01J…). It is stamped kernel-side (module code can neither supply nor suppress it) and absent on events written before the field existed. The full proof chain is not persisted — explain re-derives chains on demand; what re-derivation cannot recover once tuples have changed is which permission and grant were consulted at write time, and that pointer is what the envelope keeps.
A vertical cannot mislabel an event's origin, backdate it, attribute it to someone else, or skip emitting where an engine emits — because the fields aren't parameters and the emit sits inside the engine's operation, below anything the calling code controls.
Impersonation: two actors on one record
Supporting a customer's live vertical means seeing what a named person sees. The version of that which grows by itself is a session swap — the staff member becomes the user, and the trail says the user did it — and that is the version an audit fails. So an operation run under an impersonation session (K-42) carries two actors, and every record the scope writes about who did what keeps both:
actorstays the impersonated principal. The permission model answered about them, through the same checker with no override branch, and the domain fact is theirs. A session against somebody who holds nothing is refused precisely where they would be.impersonation: { session, by }says who was holding the keyboard — the session's id and thePlatformActorIdof the staff member who opened it. It isbyrather than a secondactorbecause that is a different question with a different answer.
Stamped kernel-side on the authorization pattern: it is not on DomainEventInput, so module code can neither claim a session it is not in nor drop the one it is — and it cannot read it either, which authorization did not need: a vertical that could see the session could hide rows from it. It is absent on every ordinary event. That absence is the honest reading — nobody was impersonating — not an unrecorded field, and reads that decode the envelope (readHistory, the denial log) return it as null for the same reason.
A session is what bounds it. HostAdmin.beginImpersonation(actor, { tenantId, scopeId, principal, reason, minutes?, mode? }) mints one and writes the admin-log row before it returns, so a durable record exists before any operation can run under it; endImpersonation closes one early. Its reason is required, its expiresAt is capped (IMPERSONATION_MAX_MINUTES, sixty) and re-read on every invoke, and its mode is read-only unless someone wrote down why not. Read-only is a mechanism, not a convention: the effecting verbs (emit, requestPlatform, grant, revoke, link) refuse by name, and the transaction is rolled back rather than committed — so a handler that writes a row with plain ctx.sql.exec and calls none of them still leaves nothing behind. The operation runs and answers; only its writes do not survive. Sessions are never deleted — one that existed is why some rows carry the stamp they do — and an expired session is a different fact from an ended one.
This is not the dev issuer's /dev/token, which mints a login as a persona for local scripts and stamps nothing: that is a development shortcut in the issuer, this is a production trail in the kernel.
PII classification is mandatory
piiClass is required at the type level — an event that could carry personal data cannot be declared without classifying it:
none— no personal data in the payload.pseudonymous— references a person by opaque ID (a technician reporting time).direct— contains direct identifiers.
And the schema enforces the invariant that makes GDPR erasure implementable: a PII-classed event without a subjectId fails validation. Crypto-shredding — erasing a person by destroying their key — must always be able to key the erasure. Facts and pseudonymous references survive (bookkeeping retention holds); the personal data becomes unreadable.
Events cross boundaries, queries don't
The composition rule for the whole system: engines and scopes integrate by reacting to each other's events, never by querying each other's tables.
The invoicing engine demonstrates the pattern. It declares in its manifest:
events: {
consumes: [{ type: 'workorder.completed', schemaVersion: 1 }],
}and registers a consumer that parses its own view of the payload — it never imports the producer's types:
const onWorkOrderCompleted: ConsumerHandler = (ctx, event) => {
const p = completedPayload.parse(event.payload); // own Zod schema of the contract
// ...write invoicing tables from the snapshot
};This is a fat event design: the payload carries what consumers need (billable lines, prices, totals), so the consumer snapshots rather than joins. Prices are frozen at the moment the event happened — which is exactly what an invoice wants.
Delivery semantics
- Consumers run as ordinary in-scope operations under a system actor (
{ system: '@substrat-run/engine-invoicing' }— visible as such in the audit trail). - Delivery is at-least-once, tracked in a kernel delivery journal — consumers must be idempotent; the event
idis the idempotency key. - Ordering is guaranteed only within one (scope, module) pair.
The connector seam
Consumers run inside the scope, so they can only touch that scope's data. Some effects are not scope-local: adding someone to an organization writes tenant-wide directory state, outside any one scope's transaction.
For those, a module asks and an executor effects:
module (in-scope) executor (out-of-band)
ctx.emit('member.add-requested') ──▶ admin.addMember(...)
commits WITH the domain write writes the audit rowhost.registerExecutor('member-adder', 'member.add-requested', async (admin, event) => {
// receives HostAdmin, not ctx — it acts with platform authority,
// which is exactly what module code must never hold
});Why not just write directly from the module? Because that would be a write to another database inside a scope transaction: two independent commits, no coordinator, and an orphaned membership if the scope rolls back after the directory write lands.
The connector has no such hazard. The event enters the outbox in the same transaction as the domain write, so a rollback leaves no event and nothing to effect. The executor then runs at-least-once from the outbox, exactly like a consumer.
Dispatch is prompt — inline after commit, with the outbox as the retry backstop. The contract is eventually consistent, because that is what makes it correct under crash, but the common case completes inside the originating request.
The trail joins. Splitting a change across two halves would otherwise split its audit trail, so admin rows carry causedBy — the id of the event that caused them. That is the event id rather than a separate correlation field: the envelope already carries a unique kernel-stamped id, and reusing it avoids widening a frozen contract to say something it already says.
How work leaves the scope
Consumers run inside the scope. Two kinds of work cannot: a platform intent needs authority the vertical does not hold, and an event bound for the long-term lake has to leave the scope's storage. Both are written in the operation's own transaction, and both are carried out later by the control plane, which is the only thing that acts on them.
The control plane learns there is work in two ways, and it needs both:
- The kick is the fast path. When an operation queued an intent, the vertical flags its response. The router, the one hop that already knows which tenant and scope it served, returns the response first and then asks the control plane to drain that scope. Intents settle in seconds instead of waiting for the next sweep.
- The sweep is the guarantee. Every 15 minutes the platform walks every active scope and drains whatever is pending, kick or no kick. A kick that was lost, failed, or never sent costs a wait, never a missed intent or event.
A kick carries no data. It names a scope, and the control plane reads that scope's rows itself, re-deriving the tenant and vertical from its own directory. So a kick can only make a scope's own pending work happen sooner. It cannot put anything into the scope or the lake, which is what makes a flag any vertical can set safe to act on.
Events leave through the sweep. Each pass reads a scope's unshipped events, sends them to the platform's Tier-2 event lake, and marks them shipped only once the lake has confirmed it holds them. The order is the safety property: a failure between sending and marking sends the same events again, which is harmless because every event has a unique id, while marking first could lose events and nothing downstream would notice. There is no kick for events yet, so they reach the lake on the sweep's schedule.
Shipping does not remove anything. The outbox is also what consumers are delivered from, what an entity's history is read from, and where an entity's version comes from, so a shipped event stays in the scope.
Audit as a product feature
Because every event carries tenant, scope, actor, entity, and time — stamped, not supplied — the event stream is the audit log: complete by construction, not by discipline. "Who did what, when, to which entity" is a query, and the answer is the same data reporting runs on.
Reading one entity's history
"Show me the history of this thing" has no source but the spine, so a projection read of _substrat_outbox is sanctioned — rule 3 bans writes to _substrat_*, not reads. What module code does not do is write the query:
import { readTimeline } from '@substrat-run/kernel';
const timelineOp = async (ctx, input) => {
const entity = timelineInput.parse(input);
assertAllowed(await ctx.check(WO.read, entity)); // the caller checks, always
return readTimeline(ctx, entity, input); // { entries, nextCursor }
};Each entry is { id, type, occurredAt, actor }. Two of those four are not what a hand-written SELECT would have got:
actoris the union, decoded. The column storesJSON.stringify(actor)overPrincipalId | { system } | { connection }, so a principal is stored with its quotes.SELECT actorreturns a string that looks usable and is not — resolving a name against it misses every time, and the obvious repair (trim the quotes) then breaks on a system actor.idis the entity's version at that point. The same tokenctx.versionOfreturns andIf-Matchcompares, so listing the history, naming a version, and refusing a stale write are one vocabulary. It is therefore the cursor:ORDER BY idis creation order, because the mint is monotonic. The timestamp inside the id is the operation's instant — the same oneoccurredAtcarries — so the cursor and the column agree about when, and paging by one does not reorder the other. Monotonicity is the stronger promise of the two, and where they part company it is the one that wins: if the clock goes backwards,occurredAtreports what it said and the id holds at the last instant it stamped, because an id that followed the clock down would bury a newer row underneath an older one. That floor survives a restart, because it is seeded from the outbox itself: the highest id a scope has already stored is read back when the scope is opened — when a host reopens its directory, and when a scope's Durable Object is revived after an eviction — so the next id clears the rows on disk whatever the clock now says. The floor is per scope, which is the scope the ordering is defined over.
Do not page a spine read on occurred_at. ctx.now() is stable for a whole invocation, so every event one operation emits carries the identical instant — a cursor of occurred_at > ? silently drops the rest of the burst it lands in.
readHistory is the same walk with payload, authorization (which permission and which grant allowed the change), impersonation (the staff actor behind the change, when there was one — see Impersonation), piiClass/subjectId, operation (the exact invoke() string the event was emitted from), version (the version the emitting code was deployed as), causedBy (the event whose delivery was in flight when this one was emitted) and invocationId (the call the event belongs to). The last four are stamped kernel-side on the same pattern as the authorization chain and the impersonation stamp: module code can neither forge nor suppress them. Seven nullables there are facts rather than gaps, and they are seven different facts:
payloadis null after an erasure — the envelope survives a shred, so a history correctly degrades to "someone changed this, then".authorizationis null when the row predates it being recorded, which is not the same as having checked nothing.impersonationis null when nobody was impersonating — the ordinary case, not an absence of recording, because the kernel stamps it on every event raised under a session and on no other. A history strip that cannot show this shows a customer's own name against a change their support engineer made.operationis null for a consumer's emit — a consumer runs on behalf of no operation, so the null is a fact, likeimpersonation's — and for a row written before the column, where it is unrecorded, likeauthorization's. This is the one null that honestly carries both neighbouring meanings, and the spine cannot tell them apart after the fact; render it as "—" rather than guess. A schedule's emit is not a consumer's: the schedule invokes an operation, so its events carry that operation's name.versionis null when no version identity was present — an undeployed host (dev or self-host SQLite with no configured id), a script deployed before theSUBSTRAT_VERSION_IDbinding existed, or a pre-column row. It is surfaced from the outbox column only: the envelopectx.emitbuilds deliberately never carries it, because which push wrote an event is script configuration rather than event data, soreadHistoryis the one sanctioned way to join an event to the deploy that produced it. A reader who expects it on the envelope, or hand-rolls aSELECTwithout the column, gets a null that looks like missing data.causedByis set only when the emit happened inside a delivery in flight — a module consumer, or a connector or platform executor, handling an event — because that is the only time a cause exists to record. It is the stepauthorizationandoperationnever wrote down: neither says why a consumer emitted, so a backwards walk used to stop at the first consumer hop. Null means an operation emitted the event directly (the ordinary case) or the row predates the column. Whereoperationis also null and this is set, the pair is decisive: the event came from a consumer, which neither field could establish alone.walkEventCauseandwalkEventEffectsin the kernel follow it in each direction, and name why a chain ends rather than stopping silently.readInvocationgroups by the call instead, which is how two events one operation raised independently — no cause between them — are read together.invocationIdis null when no call carried one — a seed, an internal call, or a row written before the column. It is the grouping key rather than a link:causedByjoins one event to one other, while this says two events were the same unit of work even when nothing causal connects them. Stamped once per invocation and carried through the post-commit tail, so a consumer's emit shares the id of the call that set it off.readInvocationis the read that uses it.
readTimeline's entry deliberately gains none of operation, version, causedBy or invocationId: the timeline is the envelope and nothing more, so there is still no disclosure decision to make there.
Field-level "X → Y" comes from diffing consecutive payloads; nothing stores a before-state. For the few fields a history strip actually shows — status, owner, value — putting the previous value in the fat payload is more honest than making every reader diff for it.
Neither read checks a permission. That stays the caller's line, above the call: a helper that gated itself would be a second, invisible policy surface, and one that gated itself on nothing would be an unchecked path into every event in the scope.