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.
| Field | Meaning | Reading rule |
|---|---|---|
sequence | global durable order assigned by SQLite | resume and projection cursors compare this value across all aggregates |
eventId | unique event identity allocated by the decider | identity is not the ordering mechanism |
aggregateKind + aggregateId | project or thread stream owning the fact | these also define the accepted receipt’s aggregate binding |
type + payload | the domain fact and its variant-specific data | projectors switch on the type; the payload is not stored in the command receipt |
occurredAt | domain timestamp selected during decision | do not use it as a replacement for global sequence order |
commandId | command that caused the event, or null | several events can share one command id |
causationEventId | direct event-level cause where one is known | for a turn start, the request event points at the user-message event |
correlationId | command-level correlation, normally the command id | groups a batch without replacing event identity |
metadata | provider ids, adapter key, request id, ingestion time, and optional client origin | metadata 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
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),
});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
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 };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.
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.
Three observations follow directly from this ordering:
- A successful commit never leaves an accepted receipt without its event batch or transactional projections.
- Hot publication is not part of that atomic unit.
- 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.
| Stored receipt | Retry aggregate | Engine result | New events / publication |
|---|---|---|---|
| accepted | same kind + id | return stored resultSequence | none / none |
| accepted | different kind or id | command-id conflict | none / none |
| rejected | same kind + id | previously-rejected error with stored detail | none / none |
| rejected | different kind or id | command-id conflict | none / none |
| no row | any | decide against current state | depends 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.
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.
- 1Accepted SQLcomplete
- 2Hot publishcomplete
- 3RPC responsereceived
- 4Providernot proven
Accepted receipt, same aggregate, same intent: return the stored sequence without new events. Provider execution is not proven.
Replay the accepted sequence
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 row | Retry target | Decision | Does body matter? |
|---|---|---|---|
| accepted | same aggregate | return stored sequence; no decide, append, or republish | no type or payload comparison |
| accepted | different aggregate | command-id conflict | conflict occurs before status replay |
| rejected | same aggregate | return previously-rejected error; no re-evaluation | no type or payload comparison |
| rejected | different aggregate | command-id conflict | conflict occurs first |
| none | any aggregate | decide the normalized command now | the supplied body is the new candidate |
Accepted-attempt delivery windows
| Window | Durable SQL | Hot delivery | Same-id retry |
|---|---|---|---|
| ordinary response received | complete batch + receipt | all events offered | stored sequence |
| hard crash after commit | complete batch + receipt | none in failed process | stored sequence; no republish |
| hard crash during publication | complete batch + receipt | some prefix; exact prefix depends on the crash point | stored sequence; no missing-suffix replay |
| response lost | complete batch + receipt | all events offered | stored sequence |
| provider later fails | complete batch + receipt | all events offered | stored sequence; no new provider request |
What a sequence acknowledgement proves
| Boundary reached | What is established | What is still not established |
|---|---|---|
| SQL commit | event batch, synchronous projections, accepted receipt | hot publication and any reactor work |
| PubSub loop completes | every event was offered to current in-memory subscribers | durable delivery or completed reactor handling |
RPC returns { sequence } | server exposes the last durable event sequence | that the response reached the client |
| client receives sequence | client knows its durable intent was accepted | provider process start, token output, or turn completion |
| provider lifecycle events arrive | the corresponding later provider milestone | milestones 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.