Events as historical evidence
Why AssetEvents are immutable, sequenced, and the source of truth for everything else.
Domain state in Fabric is derived. The source of truth is the append-only AssetEvent log. Read models, projections, dashboards, and analytics extracts are all functions of that log.
This is not a stylistic choice. It is what makes the platform's compliance and replay guarantees possible.
The event envelope
Every event conforms to AssetEventEnvelope (packages/platform/events/index.ts):
interface AssetEventEnvelope<TPayload = unknown> {
id: string;
tenantId: string;
spaceId: string;
eventType: string;
eventSchemaVersion: number;
subjectType: string;
subjectId: string;
actorId: string;
actorType: string;
actionInvocationId?: string;
payload: TPayload;
sequence: number;
occurredAt: Date;
recordedAt: Date;
correlationId: string;
causationId?: string;
}Two pairs of fields are worth highlighting:
occurredAtvsrecordedAt— when the fact happened in the world vs when the platform learned of it. Often equal; differs for backfills and webhook ingestions.correlationIdvscausationId— the conversational thread vs the immediate parent. A saga that fires three child actions ties them together with sharedcorrelationIdandcausationIdchains.
Why immutability
The event log is never updated. Mistakes are corrected by emitting compensating events, not by editing history. This buys:
- Replayability. Any projection can be deleted and rebuilt from events.
- Forensic clarity. "What did the system know at time T?" is answerable.
- Compliance evidence. A regulator asking "show me every credit pull and the consent that preceded it" is a query, not an excavation.
Why sequencing
Events carry a per-subject sequence number. The platform's replay engine uses sequence to:
- Detect missing events (gap warning).
- Detect duplicates (dedupe by
id). - Resume from a snapshot cursor without re-processing prior events.
Within a single subject (subjectType + subjectId), events have a total order. Across subjects, ordering is by recordedAt with deterministic tiebreakers.
What a mutating action must emit
The action registry enforces this:
// packages/platform/actions/index.ts
if (definition.mutatesDomain && definition.emitsEvents.length === 0) {
throw new Error(
`Action "${definition.actionId}" mutates domain but emits no events.`,
);
}In other words: a domain mutation that produces no event is a configuration bug, not a permitted shortcut.
What this changes for the developer
If you have written event-sourced code before, this is familiar. If you have not, the practical implication is:
- Don't think "update the row." Think "what happened in the world?" Name the event after that fact (
OfferAccepted, notUpdateOfferStatus). - The handler writes domain state and appends an event in one transaction. The event is the durable record; the row is a convenience for queries.
- A read screen that needs new data is usually a new projection, not a new column.
Diagram
The read models are conveniences. The log is the source.
See also
- Event schema reference — full envelope, registration, naming.
- Projection mechanics — how projections consume events.
- Audit trail — what compliance reads from the log.