FabricFabricPlatform
Platform referenceArchitecture

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:

  • occurredAt vs recordedAt — when the fact happened in the world vs when the platform learned of it. Often equal; differs for backfills and webhook ingestions.
  • correlationId vs causationId — the conversational thread vs the immediate parent. A saga that fires three child actions ties them together with shared correlationId and causationId chains.

Why immutability

The event log is never updated. Mistakes are corrected by emitting compensating events, not by editing history. This buys:

  1. Replayability. Any projection can be deleted and rebuilt from events.
  2. Forensic clarity. "What did the system know at time T?" is answerable.
  3. 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, not UpdateOfferStatus).
  • 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

On this page