Commands, invariants, and the boundary of atomicity
Client commands are authenticated and normalized before a serialized, Effectful decider plans events; an existing-thread turn commits atomically, while first-turn bootstrap is a WebSocket saga with narrower compensation.
What this chapter resolves
- Distinguish client wire commands, dispatchable commands, and trusted internal commands.
- Locate authorization, normalization, decision, and SQL transaction boundaries.
- Derive the exact two-to-four-event batch for a turn on an existing thread.
- Contrast that atomic batch with the WebSocket-only bootstrap saga and its compensation limits.
A command is a request to change orchestration state. It is not yet a fact, and it is not a direct provider call. The server first turns an accepted client shape into a dispatchable command, decides that command against its current domain model, and only then opens the transaction that makes the resulting facts durable.
That order is the key to this part of the book:
authorize → normalize → queue → deduplicate → decide → transact → publish → react
For an accepted receipt miss, crossing the first six boundaries through a successful transaction establishes durable intent. A receipt hit refers to intent committed earlier, while a rejected command establishes no accepted intent. Provider execution begins later.
“Command” names three different shapes
The contracts deliberately separate what a client may send from what the engine may receive.
afterSequence: Schema.optionalKey(NonNegativeInt),
/**
* Requests an explicit marker after the subscription has emitted its initial
* snapshot or catch-up replay and before it begins emitting live events.
*/
requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
/**
* When provided, the fallback snapshot frame (sent when `afterSequence` is
* missing or the catch-up gap is too large) is windowed to the last
* `turnLimit` user-anchored turns and carries `page` metadata. Absent means
* the fallback snapshot is the full thread, preserving pre-pagination client
* behavior. Live events are unaffected either way.
*/
turnLimit: Schema.optionalKey(PositiveInt),
});
export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;
/**
* Bounds a thread detail read to a window of recent turns. `turnLimit` counts
* turns with a user pending message (subagent/fan-out turns between them ride
* along), so the window always contains the last N user prompts. `beforeCursor`
* requests the disjoint page of older turns strictly before a previously
* returned cursor. Requests without a window get the full thread; pagination is
* strictly opt-in so older clients keep today's behavior on both HTTP and the
* WebSocket fallback snapshot.
*/
export const OrchestrationThreadDetailWindow = Schema.Struct({
turnLimit: Schema.optionalKey(PositiveInt),
beforeCursor: Schema.optionalKey(TrimmedNonEmptyString),
});
export type OrchestrationThreadDetailWindow = typeof OrchestrationThreadDetailWindow.Type;
/**
* Page metadata for a windowed thread detail read. `beforeCursor` is opaque and
* exclusive: passing it back returns the adjacent disjoint slice of older
* turns. `null` means the thread is fully loaded below this page. The
* `snapshotSequence` mirrors the top-level snapshot sequence so history pages
* can be sequence-checked against live state before merging.
*/
export const OrchestrationThreadDetailPage = Schema.Struct({
beforeCursor: Schema.NullOr(TrimmedNonEmptyString),
hasMore: Schema.Boolean,
snapshotSequence: NonNegativeInt,
/**
* Highest event sequence applied to THIS thread at page read time. The
* global `snapshotSequence` advances with every thread's events, so a
* client cannot wait for it via its per-thread subscription; this
* thread-scoped watermark is reachable. A client merging an older page
* must first have applied live events up to it — otherwise a streaming
* turn outside the loaded window could have deltas replayed on top of
* page content that already includes them, duplicating text.
*/
threadSequence: Schema.optionalKey(NonNegativeInt),
});
export type OrchestrationThreadDetailPage = typeof OrchestrationThreadDetailPage.Type;
export const OrchestrationThreadDetailSnapshot = Schema.Struct({
snapshotSequence: NonNegativeInt,
thread: OrchestrationThread,
// Present only on windowed responses. Absent on full snapshots (and from
// pre-pagination servers), which clients treat as fully loaded.
page: Schema.optional(OrchestrationThreadDetailPage),
});
export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type;
export const ProjectCreateCommand = Schema.Struct({
type: Schema.Literal("project.create"),
commandId: CommandId,
projectId: ProjectId,
title: TrimmedNonEmptyString,
workspaceRoot: TrimmedNonEmptyString,
createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
// Retained for older clients that sent an automatic create-time seed. The
// server ignores it; explicit project defaults use project.meta.update.
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
createdAt: IsoDateTime,
});
const ProjectMetaUpdateCommand = Schema.Struct({
type: Schema.Literal("project.meta.update"),
commandId: CommandId,
projectId: ProjectId,
title: Schema.optional(TrimmedNonEmptyString),
workspaceRoot: Schema.optional(TrimmedNonEmptyString),
defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
// Absent = leave unchanged; null = clear the override.
defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
autoPull: Schema.optional(Schema.Boolean),
faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
scripts: Schema.optional(Schema.Array(ProjectScript)),
});
const ProjectDeleteCommand = Schema.Struct({
type: Schema.Literal("project.delete"),
commandId: CommandId,
projectId: ProjectId,
force: Schema.optional(Schema.Boolean),
});
const ThreadCreateCommand = Schema.Struct({
type: Schema.Literal("thread.create"),
commandId: CommandId,
threadId: ThreadId,
projectId: ProjectId,
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
runtimeMode: RuntimeMode,
interactionMode: ProviderInteractionMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)),
),
branch: Schema.NullOr(TrimmedNonEmptyString),
worktreePath: Schema.NullOr(TrimmedNonEmptyString),
createdAt: IsoDateTime,
historyImport: Schema.optional(Schema.Literal(true)),
});
const ThreadDeleteCommand = Schema.Struct({
type: Schema.Literal("thread.delete"),
commandId: CommandId,
threadId: ThreadId,
});
const ThreadArchiveCommand = Schema.Struct({
type: Schema.Literal("thread.archive"),
commandId: CommandId,
threadId: ThreadId,
});
const ThreadUnarchiveCommand = Schema.Struct({
type: Schema.Literal("thread.unarchive"),
commandId: CommandId,
threadId: ThreadId,
});
const ThreadSettleCommand = Schema.Struct({
type: Schema.Literal("thread.settle"),
commandId: CommandId,
threadId: ThreadId,
});
const ThreadAutoSettleCommand = Schema.Struct({
type: Schema.Literal("thread.auto-settle"),
commandId: CommandId,
threadId: ThreadId,| Shape | Who can construct it | What changes before the next boundary |
|---|---|---|
ClientOrchestrationCommand | authenticated HTTP or WebSocket client | timestamps, workspace paths, and attachment data still need server normalization; turn start may include bootstrap instructions |
DispatchableClientOrchestrationCommand | server after client normalization | raw attachment data has become persisted attachment metadata; the shape is ready for the domain engine |
InternalOrchestrationCommand | trusted server/provider reactors | assistant deltas, sessions, plan updates, activities, diff completion, and similar runtime results re-enter the same domain engine |
“Client” and “internal” are admission boundaries, not a complete actor history. Events later infer an actor class from command-id prefixes and provider metadata. Do not interpret the internal union as “commands that are not serialized” or “commands that bypass invariants”: both dispatchable client commands and trusted internal commands enter the engine’s command union.
The full command boundary
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
An authenticated client command passes schema and scope checks, then normalization. Trusted internal commands enter through a separate boundary. Both reach one serialized command queue. The engine first checks a command receipt. A receipt hit returns its stored outcome. On a miss, an Effectful decider reads the in-memory command model and plans one or more events. Only after decision does one SQL transaction append those events, update synchronous projections, and save an accepted receipt. After commit, the engine replaces its in-memory model and publishes each event to an in-memory PubSub. The last sequence is returned only after that publication loop. Reactors consume the hot events asynchronously.
packages/contracts/src/orchestration.ts:913–1057 ↗apps/server/src/orchestration/Normalizer.ts:18–179 ↗apps/server/src/orchestration/Layers/OrchestrationEngine.ts:83–96 ↗apps/server/src/orchestration/Layers/OrchestrationEngine.ts:331–368 ↗apps/server/src/orchestration/decider.ts:18–173 ↗apps/server/src/orchestration/Layers/OrchestrationEngine.ts:197–259 ↗apps/server/src/orchestration/Layers/OrchestrationEngine.ts:142–170 ↗The queue is an important concurrency boundary. The implementation creates one unbounded command queue and drains it with one worker. Every command therefore sees the command read model produced by the commands before it. This is how an invariant check closes a race: two clients may submit concurrently, but they do not decide concurrently against the same stale model.
Normalization can perform irreversible work early
Normalization does more than validate syntax. It replaces client timestamps with the server receipt time, canonicalizes project workspace roots, validates image data, creates attachment identifiers, creates directories, and writes attachment bytes.
PENDING_ATTACHMENT_THREAD_SEGMENT,
parseThreadSegmentFromAttachmentId,
resolveAttachmentPath,
} from "../attachmentStore.ts";
import { ServerConfig } from "../config.ts";
import { parseBase64DataUrl } from "../imageMime.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
export const canonicalizeClientCommandTimestamps = (
command: ClientOrchestrationCommand,
receivedAt: IsoDateTime,
): ClientOrchestrationCommand => {
const canonicalCommand =
"createdAt" in command
? {
...command,
createdAt: receivedAt,
}
: command;
if (canonicalCommand.type !== "thread.turn.start" || !canonicalCommand.bootstrap?.createThread) {
return canonicalCommand;
}
return {
...canonicalCommand,
bootstrap: {
...canonicalCommand.bootstrap,
createThread: {
...canonicalCommand.bootstrap.createThread,
createdAt: receivedAt,
},
},
};
};
const removeClaimedAttachmentPaths = Effect.fn("Normalizer.removeClaimedAttachmentPaths")(
function* (attachmentPaths: ReadonlyArray<string>) {
if (attachmentPaths.length === 0) {
return;
}
const fileSystem = yield* FileSystem.FileSystem;
yield* Effect.forEach(
attachmentPaths,
(attachmentPath) =>
fileSystem.remove(attachmentPath, { force: true }).pipe(
Effect.tapError((cause) =>
Effect.logWarning("Failed to remove an unclaimed attachment copy.", {
attachmentPath,
cause,
}),
),
Effect.orElseSucceed(() => undefined),
),
{ concurrency: 1 },
);
},
);
export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
Effect.gen(function* () {
const receivedAt = DateTime.formatIso(yield* DateTime.now);
const canonicalCommand = canonicalizeClientCommandTimestamps(command, receivedAt);
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(
Effect.mapError(
(cause) =>
new OrchestrationDispatchCommandError({
message: cause.message,
}),
),
);
const normalizeProjectWorkspaceRootForCreate = (
workspaceRoot: string,
createIfMissing: boolean | undefined,
) =>
workspacePaths
.normalizeWorkspaceRoot(workspaceRoot, {
createIfMissing: createIfMissing === true,
})
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationDispatchCommandError({
message: cause.message,
}),
),
);
if (canonicalCommand.type === "project.create") {
return {
...canonicalCommand,
workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate(
canonicalCommand.workspaceRoot,
canonicalCommand.createWorkspaceRootIfMissing,
),
createWorkspaceRootIfMissing: canonicalCommand.createWorkspaceRootIfMissing === true,
} satisfies OrchestrationCommand;
}
if (
canonicalCommand.type === "project.meta.update" &&
canonicalCommand.workspaceRoot !== undefined
) {
return {
...canonicalCommand,
workspaceRoot: yield* normalizeProjectWorkspaceRoot(canonicalCommand.workspaceRoot),
} satisfies OrchestrationCommand;
}
if (
canonicalCommand.type !== "thread.turn.start" &&
canonicalCommand.type !== "thread.user-input.respond"
) {
return canonicalCommand as OrchestrationCommand;
}
const attachments =
canonicalCommand.type === "thread.turn.start"
? canonicalCommand.message.attachments
: Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat();
if (
canonicalCommand.type === "thread.user-input.respond" &&
attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS
) {
return yield* new OrchestrationDispatchCommandError({
message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`,
});
}
const claimedAttachmentPaths: string[] = [];
const normalizedAttachments = yield* Effect.forEach(
attachments,
(attachment) =>
Effect.gen(function* () {
if (!("dataUrl" in attachment)) {
const claim = planAttachmentClaim({
attachmentsDir: serverConfig.attachmentsDir,
threadId: canonicalCommand.threadId,
attachmentId: attachment.id,
});
if (!claim.ok) {
return yield* new OrchestrationDispatchCommandError({
message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`,
});
}
const info = yield* fileSystem.stat(claim.currentPath).pipe(
Effect.mapError(
(cause) =>
new OrchestrationDispatchCommandError({
message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`,
cause,
}),
),
);
if (Number(info.size) !== attachment.sizeBytes) {That placement has two consequences:
- An invariant rejection or later SQL failure does not automatically roll back the staged attachment bytes.
- Every client retry normalizes before receipt lookup. With no accepted receipt it can allocate new attachment identifiers before trying the transaction again; even an accepted same-id retry can stage new files before the engine returns the old receipt result.
The decider is isolated, but not a deterministic pure function
The decider receives a normalized command and the current command read model. It does not query SQLite, write files, or call a provider. It does, however, run in Effect: some cases read the clock, and every planned event receives a random UUID.
Within a turn start, the decider enforces four concrete rules or transitions:
| Question | Decision | Why it belongs here |
|---|---|---|
| Does the target thread exist? | missing thread rejects the command | the serialized model is authoritative at decision time |
| Was a proposed plan named? | the referenced plan must exist on its source thread | a structurally valid id is not proof that the plan is present |
| Does that plan cross projects? | source and target thread must belong to the same project | the decider can inspect related aggregates in one model |
| Was the target settled or snoozed? | emit lifecycle-reset events before message and turn intent | valid activity evolves state; it is not always a rejection |
An existing-thread turn is one two-to-four-event decision
{
type: "thread.pull-request.unlink" as const,
commandId: command.commandId,
threadId: command.threadId,
host: currentPullRequest.host,
repository: currentPullRequest.repository,
number: currentPullRequest.number,
},
]
: []),
{
type: "thread.pull-request.link",
commandId: command.commandId,
threadId: command.threadId,
...legacyThreadPullRequestKey(linked, host),
url: linked.url,
source: "manual",
},
],
});
}
if (command.linkedPullRequest === null && currentPullRequest !== null) {
const { linkedPullRequest: _linkedPullRequest, ...metadata } = command;
const hasMetadata = Object.entries(metadata).some(
([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined,
);
return yield* decideCommandSequence({
readModel,
commands: [
...(hasMetadata ? [metadata] : []),
{
type: "thread.pull-request.unlink",
commandId: command.commandId,
threadId: command.threadId,
host: currentPullRequest.host,
repository: currentPullRequest.repository,
number: currentPullRequest.number,
},
],
});
}
const branch =
command.branch !== undefined &&
command.expectedBranch !== undefined &&
thread.branch !== command.expectedBranch
? thread.branch
: command.branch;
const occurredAt = yield* nowIso;
return {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt,
commandId: command.commandId,
})),
type: "thread.meta-updated",
payload: {
threadId: command.threadId,
...(command.title !== undefined ? { title: command.title } : {}),
...(command.regenerateTitle === true
? {
regenerateTitle: true as const,
previousTitle: thread.title,
titleRegeneration: {
requestId: command.commandId,
startedAt: occurredAt,
},
}
: {}),
...(command.title !== undefined && thread.titleRegeneration != null
? { titleRegeneration: null }
: {}),
...(command.modelSelection !== undefined
? { modelSelection: command.modelSelection }
: {}),
...(branch !== undefined ? { branch } : {}),
...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
...(command.linkedPullRequest !== undefined
? { linkedPullRequest: command.linkedPullRequest }
: {}),
updatedAt: occurredAt,
},
};
}
case "thread.pull-request.link": {
const thread = yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
const key = normalizeThreadPullRequestKey(command);
const existing = findPullRequestLink(thread, key);
// An explicit link on a dismissed stack member un-dismisses it; any
// other duplicate is a no-op the engine would reject as zero-event.
const undismisses =
existing?.source === "stack-dismissed" &&
(command.source === "manual" || command.source === "agent" || command.source === "created");
if (existing !== undefined && !undismisses) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`,
});
}
const occurredAt = yield* nowIso;
return {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt,For a normal turn on an existing thread, the planned batch is:
- optional
thread.unsettled; - optional
thread.unsnoozed; - mandatory
thread.message-sent; - mandatory
thread.turn-start-requested, causally linked to the message event.
The engine opens one SQL transaction only after that complete batch exists. It appends each event, folds it into a temporary command model, applies all synchronous SQLite projectors, and finally upserts one accepted receipt whose result is the last event’s global sequence. A failure in any one of those SQL operations rolls back the whole batch.
First-turn bootstrap is a WebSocket saga
The first turn can ask the server to create a thread, prepare a Git worktree, run a setup script, and then start the turn. Those operations cannot share the existing-turn transaction: Git and process launch are external effects, and several domain subcommands each commit independently.
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
A WebSocket client sends a turn-start command with bootstrap data. After normalization, the WebSocket handler may dispatch thread.create as SQL transaction A with a server-generated command id. It can then fetch and create a Git worktree outside SQL. It dispatches thread.meta.update as SQL transaction B, refreshes Git status, and optionally launches a setup script outside SQL. Setup launch failure is recorded best effort and the saga continues. The handler strips bootstrap data and dispatches the final thread.turn.start as SQL transaction C using the outer command id. If a later non-interruption failure occurs after this saga created the thread, it dispatches thread.delete as compensation transaction D. The compensation tombstones domain state but contains no Git-worktree removal.
The saga’s real boundaries are easy to miss:
thread.create,thread.meta.update, and compensation use fresh server-generated command ids; the final turn retains the client’s outer id.- Setup launch failure is converted into best-effort activity plus a warning, then the final turn is still attempted.
- Cleanup runs only when this request successfully created the thread and the later failure is not interruption-only.
- Successful cleanup reports a
deleteddisposition, but it adds a thread tombstone; it does not erase the committed history.
Explore the failure boundary
The same word—“failed”—can describe a request rejected before decision, SQL rolled back after decision, a hard crash after commit, or a bootstrap failure after Git state exists. Move the boundary and compare what survives.
Where can a command fail?
Switch paths and move the failure position. Watch which state is durable, which state is external, and what an identical retry can actually recover.
One normalized command is decided, then its two-to-four-event batch, projections, and accepted receipt share one SQL transaction.
Existing-thread path, position 1 of 6: Before normalization. No durable intent.
Before normalization
The client command has crossed schema and authorization checks, but normalization has not produced the dispatchable command.
- Durable state
- No orchestration event or command receipt exists.
- External state
- No attachment staging or provider work has begun.
- Client sees
- The request can fail without a durable orchestration result.
- Identical retry
- The same command id enters the normal path again because there is no receipt to find.
- Provider implication
- No provider intent exists.
Static boundary ledger
Turn on an existing thread
One normalized command is decided, then its two-to-four-event batch, projections, and accepted receipt share one SQL transaction.
| Position | Durable / external state | Retry and provider meaning |
|---|---|---|
| 1. Before normalization | No orchestration event or command receipt exists. No attachment staging or provider work has begun. | The same command id enters the normal path again because there is no receipt to find. No provider intent exists. |
| 2. After normalization and staging | The event store and receipt table are still unchanged. Filesystem staging can already exist outside the later SQL transaction. | The command is normalized and staged again; receipt deduplication has not started yet. No provider intent exists. |
| 3. Invariant rejection | No event is appended. A rejected receipt is attempted afterward on a best-effort path. Any earlier normalization-time staging is outside that rejection bookkeeping. | After client normalization runs again, a saved rejected receipt keeps the same id rejected without re-evaluation; if that receipt was not saved, the command can be decided again. No provider intent is published. |
| 4. SQL transaction failure | Events, transactional projections, and the accepted receipt roll back together. Pre-transaction staged files are not part of the SQL rollback. | With no accepted receipt, the same id is normalized and decided again; clock and UUID-derived values may differ. No committed event reaches the hot event bus. |
| 5. Hard crash after commit | The complete event batch, projections, and accepted receipt are durable. The hot reactor saw none of this batch in the failed process. | After any client normalization work, a same-id retry returns the stored last sequence and does not republish the missing hot events. Durable turn intent can exist without the provider reactor receiving its trigger. |
| 6. Sequence acknowledged | Two to four events, their synchronous projections, and one accepted receipt are committed. Publication wakes asynchronous reactors; provider execution is outside this transaction. | The same id and aggregate return that stored sequence without deciding or appending again. The acknowledgement proves durable intent, not that a harness accepted, ran, or completed the turn. |
Full first-turn WebSocket bootstrap
The full WebSocket path creates a thread, prepares a worktree, launches setup, and starts the turn through separate durability boundaries.
| Position | Durable / external state | Retry and provider meaning |
|---|---|---|
| 1. After normalization, before the saga | No bootstrap step has committed. Attachment staging may already exist, but no worktree or setup process has been requested. | The same outer id has no saga-level receipt that can replay the whole result. No provider intent exists. |
| 2. Thread creation committed | The thread event and that subcommand's receipt are committed independently of the final turn. No worktree is guaranteed yet. | Repeating the outer request tries the creation path again; it does not retrieve one receipt for the whole saga. The thread exists, but no turn-start request has been committed. |
| 3. Worktree prepared and metadata committed | Thread metadata can point at the newly prepared worktree in a separate event and receipt. A branch and worktree now exist outside the event-store transaction. | An identical outer retry can collide with already-created thread, branch, or worktree state. No final turn intent is guaranteed yet. |
| 4. Setup launch fails | Failure activity is best effort; the earlier thread and metadata commits remain. No setup process started, but the prepared worktree remains. | The saga continues to the final turn; this is not a rollback boundary. The eventual provider turn may run against a worktree whose setup did not launch. |
| 5. Final turn fails; compensate | Successful compensation tombstones the thread in another independent commit; prior history is not erased. The Git worktree and branch are not removed by this compensation. | Retrying the outer request is a new saga attempt, not a replay of an atomic transaction. No successful final turn acknowledgement exists. |
| 6. Final turn acknowledged | Earlier saga commits plus the final two-to-four-event turn transaction are durable, each with its own receipt boundary. Git/setup effects remain outside SQL and provider work begins asynchronously from hot events. | The outer bootstrap is not end-to-end idempotent even though each dispatched subcommand has receipt semantics. The acknowledgement proves durable turn intent only. |
The durable unit is deliberately small: an accepted engine command and its event batch. Everything before it can leave staging residue; everything after it can miss hot delivery; and the bootstrap path composes several such units with external effects. Chapter 25 examines the receipt and publication windows inside that unit.