FabricFabricPlatform
Platform referenceAction pipeline

Event emission and sequencing

How AssetEvents are appended, ordered, and bound to the action that caused them.

Event emission

A mutating action must emit at least one AssetEvent. The action registry enforces this at registration time:

// packages/platform/actions/index.ts
if (definition.mutatesDomain && definition.emitsEvents.length === 0) {
  throw new Error(
    `Action "${definition.actionId}" mutates domain but emits no events.`,
  );
}

This is not a style preference. The event log is the source of truth for everything else (read models, projections, audit, analytics). An action that mutates state without emitting an event creates a divergence.

What gets emitted

The handler appends events via the platform's event-append API in the same transaction as its domain writes. Each event becomes an AssetEventEnvelope:

interface AssetEventEnvelope<TPayload = unknown> {
  id: string;                // evt_<ULID>
  tenantId: string;
  spaceId: string;
  eventType: string;         // e.g. "OfferAccepted"
  eventSchemaVersion: number;
  subjectType: string;       // e.g. "Offer"
  subjectId: string;
  actorId: string;
  actorType: string;
  actionInvocationId?: string;
  payload: TPayload;
  sequence: number;          // per-subject monotonic
  occurredAt: Date;
  recordedAt: Date;
  correlationId: string;
  causationId?: string;
}

Sequencing model

FieldScopeProperty
sequencePer (subjectType, subjectId)Strictly monotonic. Gaps are flagged on replay.
recordedAtGlobalWhen the platform appended it.
occurredAtPer eventWhen the underlying fact happened.
correlationIdPer request threadShared across all events of a related invocation.
causationIdImmediate parent invocationForms a tree when sagas spawn child actions.

Within a subject, events have a total order. Across subjects, the global ordering uses recordedAt with deterministic tiebreakers (action ID, correlation, subject, sequence — see packages/platform/projections/index.ts:compareGlobalEvents).

Naming conventions

Event types are PascalCase past-tense facts:

GoodBad
OfferAcceptedAcceptOffer (imperative)
CreditPullCompletedUpdateCreditStatus (CRUD)
LeadIngestedLeadCreate (verb-noun mix)

Past tense matters: events describe what happened, not what is being requested. The verb form belongs on the action ID (lending.accept_offer), not the event.

Event type registry

A vertical declares its event types in its FabricModule:

{
  eventTypes: [
    { eventType: "OfferAccepted", schema: offerAcceptedPayloadSchema, version: 1 },
    "OfferDeclined",  // shorthand: name only
  ],
}

The platform pre-registers a small set of cross-cutting event types:

  • ComplianceBlocked
  • StateTransitioned
  • AdapterInvocationStarted, AdapterInvocationSucceeded, AdapterInvocationFailed
  • WebhookReceived

These are emitted by the platform itself, not by vertical code.

Schema versioning

eventSchemaVersion carries an integer per event type. When you change a payload shape:

  1. Bump the version.
  2. Add a new schema for the new version.
  3. Keep the old schema deserializable for replay (events from before the change still exist).

The platform's projection engine does not migrate old events — it replays them as they were. Projections that read both versions handle the difference.

Diagram

The transaction boundary is the guarantee. If the commit fails, neither the domain write nor the event lands.

See also

On this page