Part III · Transactional domain core and post-commit deliveryEvents and receipts
Chapter 10source checked

Events, receipts, idempotency, and the post-commit gap

Events record durable domain facts, command receipts bind an id to one aggregate but not its payload, and hot publication after commit leaves a crash window that an acknowledgement cannot close.

What this chapter resolves
  • Read the durable event envelope without confusing internal storage columns with public fields.
  • Derive accepted, rejected, conflict, and no-receipt retry behavior.
  • Identify exactly what shares the SQL transaction and what happens after commit.
  • Explain why a returned sequence proves durable intent but not provider execution.

T3 Code persists two related ledgers for every accepted command:

  • the event ledger says what happened, in global order;
  • the receipt ledger says what result belongs to this command id.

They commit together, but they answer different questions. An event supports replay and projection. A receipt prevents one command id from creating a second domain transaction for the same aggregate. Neither ledger records whether a provider harness ultimately completed the work triggered by that intent.

The public event envelope

Every event variant combines the same base envelope with a type-specific payload.

Meaning of fields in the public orchestration event envelope
FieldMeaningReading rule
sequenceglobal durable order assigned by SQLiteresume and projection cursors compare this value across all aggregates
eventIdunique event identity allocated by the decideridentity is not the ordering mechanism
aggregateKind + aggregateIdproject or thread stream owning the factthese also define the accepted receipt’s aggregate binding
type + payloadthe domain fact and its variant-specific dataprojectors switch on the type; the payload is not stored in the command receipt
occurredAtdomain timestamp selected during decisiondo not use it as a replacement for global sequence order
commandIdcommand that caused the event, or nullseveral events can share one command id
causationEventIddirect event-level cause where one is knownfor a turn start, the request event points at the user-message event
correlationIdcommand-level correlation, normally the command idgroups a batch without replacing event identity
metadataprovider ids, adapter key, request id, ingestion time, and optional client originmetadata carries attribution and integration context, not aggregate state itself

The event store therefore has two orders with different visibility:

  • a global sequence, exposed on every event and used for resumable reads;
  • a per-stream stream_version, computed and stored internally.

The per-stream version is not an expected-version token in the command protocol. As Chapter 9 showed, one serialized command worker prevents concurrent decisions inside a server process.

A receipt is smaller than the command it deduplicates

apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts:25–33 ↗verbatim · typescript · 264f74e9
export const OrchestrationCommandReceipt = Schema.Struct({
  commandId: CommandId,
  aggregateKind: OrchestrationAggregateKind,
  aggregateId: Schema.Union([ProjectId, ThreadId]),
  acceptedAt: IsoDateTime,
  resultSequence: NonNegativeInt,
  status: OrchestrationCommandReceiptStatus,
  error: Schema.NullOr(Schema.String),
});
Read this as: The row binds commandId to aggregate kind/id, a timestamp, result sequence, status, and optional error. It has no command type, serialized body, or payload hash.

For an accepted command, resultSequence is the sequence of the last event in its batch. For an invariant rejection, the same schema is used differently: no event is appended, status is rejected, error carries the rejection, and resultSequence is the command model’s current snapshot sequence. The field named acceptedAt is populated for both statuses.

This is deduplication by caller discipline: the caller must treat a command id as the permanent name of one intent. The server catches reuse across aggregates, but it cannot catch changed intent within the same aggregate.

Accepted events, projections, and receipt share one transaction

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:197–259 ↗verbatim · typescript · 24e00da0
        const committedCommand = yield* sql
          .withTransaction(
            Effect.gen(function* () {
              const committedEvents: OrchestrationEvent[] = [];
              let nextCommandReadModel = commandReadModel;
 
              for (const nextEvent of eventBases) {
                const savedEvent = yield* eventStore.append(nextEvent);
                nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent);
                yield* projectionPipeline.projectEvent(savedEvent);
                committedEvents.push(savedEvent);
              }
 
              const lastSavedEvent = committedEvents.at(-1) ?? null;
              if (lastSavedEvent === null) {
                return yield* new OrchestrationCommandInvariantError({
                  commandType: envelope.command.type,
                  detail: "Command produced no events.",
                });
              }
 
              yield* commandReceiptRepository.upsert({
                commandId: envelope.command.commandId,
                aggregateKind: lastSavedEvent.aggregateKind,
                aggregateId: lastSavedEvent.aggregateId,
                acceptedAt: lastSavedEvent.occurredAt,
                resultSequence: lastSavedEvent.sequence,
                status: "accepted",
                error: null,
              });
 
              return {
                committedEvents,
                lastSequence: lastSavedEvent.sequence,
                nextCommandReadModel,
              } as const;
            }),
          )
          .pipe(
            Effect.catchTag("SqlError", (sqlError) =>
              Effect.fail(
                toPersistenceSqlError("OrchestrationEngine.processEnvelope:transaction")(sqlError),
              ),
            ),
          );
 
        commandReadModel = committedCommand.nextCommandReadModel;
        for (const [index, event] of committedCommand.committedEvents.entries()) {
          yield* PubSub.publish(eventPubSub, event);
          if (index === 0) {
            yield* Metric.update(
              Metric.withAttributes(
                orchestrationCommandAckDuration,
                metricAttributes({
                  ...baseMetricAttributes,
                  ackEventType: event.type,
                }),
              ),
              Duration.millis(Math.max(0, (yield* Clock.currentTimeMillis) - envelope.startedAtMs)),
            );
          }
        }
        return { sequence: committedCommand.lastSequence };
Read this as: Decision has already finished when this excerpt begins. Event append, temporary model fold, synchronous SQLite projections, and the accepted receipt run inside withTransaction. The authoritative in-memory assignment and PubSub loop follow the commit; the last sequence returns after that loop.
Figure 10.1 · One SQL commit, then a volatile delivery bridgethe dashed branch marks the hard-crash window
Durable transaction followed by in-memory event publicationDiagram loading

Zoom with the controls, +/, or Ctrl/ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.

Durable transaction followed by in-memory event publication
Text equivalent

The decider first plans an event batch outside SQL. One SQL transaction appends every event, applies every synchronous SQLite projection, and writes an accepted command receipt holding the last sequence. Commit makes all three durable at once. After commit, the engine assigns its next in-memory read model. It then publishes each committed event to an in-memory PubSub. A process crash between commit and the first publication leaves durable intent and a receipt but no hot event. A process crash midway can leave only a publication prefix. Only after the publication loop does the engine return the last sequence. Provider and other reactors consume those hot events asynchronously.

Figure 10.1. Event rows, synchronous projection rows, and the accepted receipt commit atomically. The engine then swaps its in-memory command model and publishes the batch event by event to an in-memory PubSub. The RPC result follows publication but cannot make that post-commit bridge durable.

Three observations follow directly from this ordering:

  1. A successful commit never leaves an accepted receipt without its event batch or transactional projections.
  2. Hot publication is not part of that atomic unit.
  3. The RPC result waits for the publication loop, but publication only offers events to in-memory subscribers; it does not wait for provider work to finish.

Duplicate behavior is a five-row matrix

Receipt lookup happens before the decider. Aggregate comparison happens before receipt-status replay.

Engine outcome for each durable receipt and retry target combination
Stored receiptRetry aggregateEngine resultNew events / publication
acceptedsame kind + idreturn stored resultSequencenone / none
accepteddifferent kind or idcommand-id conflictnone / none
rejectedsame kind + idpreviously-rejected error with stored detailnone / none
rejecteddifferent kind or idcommand-id conflictnone / none
no rowanydecide against current statedepends on current decision

An accepted same-aggregate retry does not replay the original events into the hot stream. It returns the stored sequence immediately. This is desirable after a lost RPC response when the original publication completed. It cannot repair a commit-to-publication crash gap.

Persistence failures inside the accepted transaction are different. The transaction rolls back, no accepted receipt remains, and the failure is not recorded as a domain invariant rejection. A retry can run the decision and transaction again.

The hard-crash gap is real

After SQL commit, the engine first replaces its in-memory model, then publishes each committed event sequentially. The pinned production-code audit found no outbox row whose delivery acknowledgement commits with the events.

The failure handler does provide a narrower repair path while the process is still alive. For most dispatch failures, it reads the durable tail after the command’s starting sequence, folds that tail back into the command model, and republishes it. That can reconcile a recoverable post-commit failure in the same process. It cannot run after a hard process crash, and it is not a durable outbox protocol. If a prefix was already published before a recoverable failure, replaying the durable tail can offer that prefix again; reconciliation is not exactly-once delivery.

Explore receipts and crash windows

The lab starts with an accepted receipt. Change its status, target aggregate, body, and original delivery window. In particular, compare “response lost” with “hard crash after commit”: both can look like silence to the first caller, but only one preserves the original hot publication.

Interactive retry laboratory

What does this command id remember?

Configure the durable receipt and the original delivery window, then replay the id. The result separates SQL deduplication from hot delivery and provider execution.

The delivery window describes the accepted attempt whose receipt is being replayed.

  1. 1Accepted SQLcomplete
  2. 2Hot publishcomplete
  3. 3RPC responsereceived
  4. 4Providernot proven

Accepted receipt, same aggregate, same intent: return the stored sequence without new events. Provider execution is not proven.

Engine decision

Replay the accepted sequence

deduplicated

The engine finds the accepted row before the decider and returns its stored resultSequence.

Durable record
The original event batch, projections, and accepted receipt remain committed.
Events and delivery
The retry appends zero events and republishes zero events.
Client result
The retry receives the stored last sequence.
Payload binding
The receipt checks aggregate kind and id, not command type or a payload fingerprint.
Provider meaning
The sequence acknowledges durable intent only; provider acceptance or completion is outside the receipt.
Static receipt and crash-window matrices

Receipt decision matrix

Stored rowRetry targetDecisionDoes body matter?
acceptedsame aggregatereturn stored sequence; no decide, append, or republishno type or payload comparison
accepteddifferent aggregatecommand-id conflictconflict occurs before status replay
rejectedsame aggregatereturn previously-rejected error; no re-evaluationno type or payload comparison
rejecteddifferent aggregatecommand-id conflictconflict occurs first
noneany aggregatedecide the normalized command nowthe supplied body is the new candidate

Accepted-attempt delivery windows

WindowDurable SQLHot deliverySame-id retry
ordinary response receivedcomplete batch + receiptall events offeredstored sequence
hard crash after commitcomplete batch + receiptnone in failed processstored sequence; no republish
hard crash during publicationcomplete batch + receiptsome prefix; exact prefix depends on the crash pointstored sequence; no missing-suffix replay
response lostcomplete batch + receiptall events offeredstored sequence
provider later failscomplete batch + receiptall events offeredstored sequence; no new provider request

What a sequence acknowledgement proves

Guarantees established and not established at each completion boundary
Boundary reachedWhat is establishedWhat is still not established
SQL commitevent batch, synchronous projections, accepted receipthot publication and any reactor work
PubSub loop completesevery event was offered to current in-memory subscribersdurable delivery or completed reactor handling
RPC returns { sequence }server exposes the last durable event sequencethat the response reached the client
client receives sequenceclient knows its durable intent was acceptedprovider process start, token output, or turn completion
provider lifecycle events arrivethe corresponding later provider milestonemilestones not yet represented by a later event

The right mental model is deliberately narrow: receipt idempotency protects one serialized domain commit. It is not payload equality, not normalization idempotency, not saga idempotency, not durable reactor delivery, and not provider exactly-once execution.

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.