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.
const DispatchableClientOrchestrationCommand = Schema.Union([
ProjectCreateCommand,
ProjectMetaUpdateCommand,
ProjectDeleteCommand,
ThreadCreateCommand,
ThreadDeleteCommand,
ThreadArchiveCommand,
ThreadUnarchiveCommand,
ThreadSettleCommand,
ThreadUnsettleCommand,
ThreadSnoozeCommand,
ThreadUnsnoozeCommand,
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
ThreadMetaUpdateCommand,
ThreadRuntimeModeSetCommand,
ThreadInteractionModeSetCommand,
ThreadTurnStartCommand,
ThreadTurnInterruptCommand,
ThreadApprovalRespondCommand,
ThreadUserInputRespondCommand,
ThreadCheckpointRevertCommand,
ThreadSessionStopCommand,
]);
export type DispatchableClientOrchestrationCommand =
typeof DispatchableClientOrchestrationCommand.Type;
export const ClientOrchestrationCommand = Schema.Union([
ProjectCreateCommand,
ProjectMetaUpdateCommand,
ProjectDeleteCommand,
ThreadCreateCommand,
ThreadDeleteCommand,
ThreadArchiveCommand,
ThreadUnarchiveCommand,
ThreadSettleCommand,
ThreadUnsettleCommand,
ThreadSnoozeCommand,
ThreadUnsnoozeCommand,
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
ThreadMetaUpdateCommand,
ThreadRuntimeModeSetCommand,
ThreadInteractionModeSetCommand,
ClientThreadTurnStartCommand,
ThreadTurnInterruptCommand,
ThreadApprovalRespondCommand,
ThreadUserInputRespondCommand,
ThreadCheckpointRevertCommand,
ThreadSessionStopCommand,
]);
export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
const ThreadSessionSetCommand = Schema.Struct({
type: Schema.Literal("thread.session.set"),
commandId: CommandId,
threadId: ThreadId,
session: OrchestrationSession,
createdAt: IsoDateTime,
});
const ThreadMessageAssistantDeltaCommand = Schema.Struct({
type: Schema.Literal("thread.message.assistant.delta"),
commandId: CommandId,
threadId: ThreadId,
messageId: MessageId,
delta: Schema.String,
turnId: Schema.optional(TurnId),
createdAt: IsoDateTime,
});
const ThreadMessageAssistantCompleteCommand = Schema.Struct({
type: Schema.Literal("thread.message.assistant.complete"),
commandId: CommandId,
threadId: ThreadId,
messageId: MessageId,
turnId: Schema.optional(TurnId),
createdAt: IsoDateTime,
});
const ThreadProposedPlanUpsertCommand = Schema.Struct({
type: Schema.Literal("thread.proposed-plan.upsert"),
commandId: CommandId,
threadId: ThreadId,
proposedPlan: OrchestrationProposedPlan,
createdAt: IsoDateTime,
});
const ThreadTurnDiffCompleteCommand = Schema.Struct({
type: Schema.Literal("thread.turn.diff.complete"),
commandId: CommandId,
threadId: ThreadId,
turnId: TurnId,
completedAt: IsoDateTime,
checkpointRef: CheckpointRef,
status: OrchestrationCheckpointStatus,
files: Schema.Array(OrchestrationCheckpointFile),
assistantMessageId: Schema.optional(MessageId),
checkpointTurnCount: NonNegativeInt,
createdAt: IsoDateTime,
});
const ThreadActivityAppendCommand = Schema.Struct({
type: Schema.Literal("thread.activity.append"),
commandId: CommandId,
threadId: ThreadId,
activity: OrchestrationThreadActivity,
createdAt: IsoDateTime,
});
const ThreadRevertCompleteCommand = Schema.Struct({
type: Schema.Literal("thread.revert.complete"),
commandId: CommandId,
threadId: ThreadId,
turnCount: NonNegativeInt,
createdAt: IsoDateTime,
});
const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
type: Schema.Literal("thread.title.regeneration.complete"),
commandId: CommandId,
threadId: ThreadId,
requestId: CommandId,
title: Schema.optional(TrimmedNonEmptyString),
});
const InternalOrchestrationCommand = Schema.Union([
ThreadSessionSetCommand,
ThreadMessageAssistantDeltaCommand,
ThreadMessageAssistantCompleteCommand,
ThreadProposedPlanUpsertCommand,
ThreadTurnDiffCompleteCommand,
ThreadActivityAppendCommand,
ThreadRevertCompleteCommand,
ThreadTitleRegenerationCompleteCommand,
]);
export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
export const OrchestrationCommand = Schema.Union([
DispatchableClientOrchestrationCommand,
InternalOrchestrationCommand,
]);
export type OrchestrationCommand = typeof OrchestrationCommand.Type;| 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.
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,
},
},
};
};
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") {
return canonicalCommand as OrchestrationCommand;
}
const normalizedAttachments = yield* Effect.forEach(
canonicalCommand.message.attachments,
(attachment) =>
Effect.gen(function* () {
const parsed = parseBase64DataUrl(attachment.dataUrl);
if (!parsed || !parsed.mimeType.startsWith("image/")) {
return yield* new OrchestrationDispatchCommandError({
message: `Invalid image attachment payload for '${attachment.name}'.`,
});
}
const bytes = Buffer.from(parsed.base64, "base64");
if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
return yield* new OrchestrationDispatchCommandError({
message: `Image attachment '${attachment.name}' is empty or too large.`,
});
}
const attachmentId = createAttachmentId(canonicalCommand.threadId);
if (!attachmentId) {
return yield* new OrchestrationDispatchCommandError({
message: "Failed to create a safe attachment id.",
});
}
const persistedAttachment = {
type: "image" as const,
id: attachmentId,
name: attachment.name,
mimeType: parsed.mimeType.toLowerCase(),
sizeBytes: bytes.byteLength,
};
const attachmentPath = resolveAttachmentPath({
attachmentsDir: serverConfig.attachmentsDir,
attachment: persistedAttachment,
});
if (!attachmentPath) {
return yield* new OrchestrationDispatchCommandError({
message: `Failed to resolve persisted path for '${attachment.name}'.`,
});
}
yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe(
Effect.mapError(
() =>
new OrchestrationDispatchCommandError({
message: `Failed to create attachment directory for '${attachment.name}'.`,
}),
),
);
yield* fileSystem.writeFile(attachmentPath, bytes).pipe(
Effect.mapError(
() =>
new OrchestrationDispatchCommandError({
message: `Failed to persist attachment '${attachment.name}'.`,
}),
),
);
return persistedAttachment;
}),
{ concurrency: 1 },
);
return {
...canonicalCommand,
message: {
...canonicalCommand.message,
attachments: normalizedAttachments,
},
} satisfies OrchestrationCommand;
});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
case "thread.turn.start": {
const targetThread = yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
const sourceProposedPlan = command.sourceProposedPlan;
const sourceThread = sourceProposedPlan
? yield* requireThread({
readModel,
command,
threadId: sourceProposedPlan.threadId,
})
: null;
const sourcePlan =
sourceProposedPlan && sourceThread
? sourceThread.proposedPlans.find((entry) => entry.id === sourceProposedPlan.planId)
: null;
if (sourceProposedPlan && !sourcePlan) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `Proposed plan '${sourceProposedPlan.planId}' does not exist on thread '${sourceProposedPlan.threadId}'.`,
});
}
if (sourceThread && sourceThread.projectId !== targetThread.projectId) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`,
});
}
const userMessageEvent: Omit<OrchestrationEvent, "sequence"> = {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.message-sent",
payload: {
threadId: command.threadId,
messageId: command.message.messageId,
role: "user",
text: command.message.text,
attachments: command.message.attachments,
turnId: null,
streaming: false,
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
};
const turnStartRequestedEvent: Omit<OrchestrationEvent, "sequence"> = {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
causationEventId: userMessageEvent.eventId,
type: "thread.turn-start-requested",
payload: {
threadId: command.threadId,
messageId: command.message.messageId,
...(command.modelSelection !== undefined
? { modelSelection: command.modelSelection }
: {}),
...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}),
runtimeMode: targetThread.runtimeMode,
interactionMode: targetThread.interactionMode,
...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}),
createdAt: command.createdAt,
},
};
// Real activity resets ANY override: it wakes an explicitly settled
// thread, and it clears a keep-active pin back to neutral so the
// thread can auto-settle again after this burst of work goes stale.
// A snooze clears the same way — sending a message to a snoozed
// thread is the user re-engaging, so the return ticket is spent.
const lifecycleResetEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
if (targetThread.settledOverride !== null) {
lifecycleResetEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.unsettled",
payload: {
threadId: command.threadId,
reason: "activity",
updatedAt: command.createdAt,
},
});
}
if (targetThread.snoozedUntil != null) {
lifecycleResetEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.unsnoozed",
payload: {
threadId: command.threadId,
reason: "activity",
updatedAt: command.createdAt,
},
});
}
return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent];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 10 now examines the receipt and publication windows inside that unit.