Commands and idempotency
Every state-changing method in MSP — session lifecycle and command plane
alike — carries a client-generated commandId, a UUIDv7 the SDK mints once
per logical command. The server never mints one, and a missing commandId
is an invalidParams
error: it is the only retry handle that survives reconnects.
The command model
Section titled “The command model”The command plane is thin by design. A response tells you only what the
runtime decided at admission — accepted, duplicate, or rejected — and
everything that actually happened arrives on the session view as
cursor-bearing events; a response payload is never the only place a fact
lives. Successful acks share a common envelope,
commandAcceptedResult:
the echoed commandId, an accepted
status, and per-method
fields such as the turn id. Admission failures are typed JSON-RPC errors,
with durable runtime rejections carried as
commandRejected
plus a stable snake_case reason string; unknown reasons must be treated as
terminal rejections.
Commands require the target session to be loaded on this host: a command
against an unloaded session fails
sessionNotLoaded,
and a session whose lease a foreign process holds fails
sessionInUse.
Here is a stop command and its admission ack from a recorded conformance
scenario — note the echoed commandId and that the ack asserts admission
only, not that the turn has stopped:
{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"turn/cancel\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a21-0f0f-7aaa-bbbb-0123456789ab\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"}}"}{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{\"commandId\":\"018f6a21-0f0f-7aaa-bbbb-0123456789ab\",\"status\":\"accepted\",\"turnId\":\"018f6a1e-9b3c-7c21-a54a-2f30bd3c9f10\"}}"}The three idempotency properties
Section titled “The three idempotency properties”- Exactly-once effect. Submitting the same
commandIdtwice — a retry after a dropped connection, a reconnect replay, a duplicated line — applies the command once, and the duplicate receives a value-identical ack: the same members carrying the same values, recursively. The server does not distinguish a first ack from a replay ack. - Durable dedupe. The idempotency ledger is not connection state or
process memory: each accepted command writes a durable intake record before
its ack is sent, so a
commandIdreplayed after a host crash and restart still deduplicates. - Payload identity is checked. Reusing a
commandIdwith a different payload is a client bug, not a retry: the server compares against the originally captured payload signature and rejects the mismatch with thecommand_id_conflictreason.
Rejections are idempotent too: replaying a commandId whose command was
durably rejected returns the same rejection.
Cross-restart dedupe and the durable intake record
Section titled “Cross-restart dedupe and the durable intake record”Before the server acks an admitted command, the runtime appends a command intake pair to the session log: a received record at admission and a settled record at the terminal outcome. Three consequences an SDK can rely on:
- Ack-then-crash is safe. If you received an ack, the intake is durable;
if you did not, replaying the same
commandIdis always safe for target-anchored commands. - Intake without settlement is pending, not lost. After a host restart,
recovery settles it — usually as abandoned — and a replay of an abandoned
intake answers a durable rejection with the abandoned reason, never a stale
pending ack. Never invent a terminal client-side; observe the settlement
through a
commandIdreplay or the resume snapshot. - Late joiners see pending commands. The pending-command set is a log read, exactly like pending approvals — not connection state.
There are ruled exceptions to the durable ledger. The interrupt/cancel row and the background-task stop rows are deliberately carrier-less at v1, so their dedupe is host-lifetime-only — acceptable because the running work each targets cannot survive the restart. The practical guidance: target-anchored replays (an explicit turn id or task id) answer with no new effect, while target-less forms — a stop-all sweep, or an id-less interrupt or cancel — are re-resolved fresh at a restarted host and may act on work the original gesture never aimed at. Pass the explicit id whenever you have one.
On an ephemeral host the intake pair is still written and every idempotency
property holds for the host’s lifetime, but nothing survives host death: a
client must not replay a commandId at a new host after an ephemeral host
dies. See durability profiles.
How turn ids are minted
Section titled “How turn ids are minted”For a fresh turn, the run stream is derived from the submitting command’s id:
the commandId becomes the turnId, so the ack, the
turn/started event,
and every item event of that turn carry the same string and correlate without
a join table. A steered submit does not mint a new turn — its ack names the
running turn that absorbed the input. A queued submit mints its follow-up
turn’s id at admission, so the ack always names the turn that will run your
input, whether it started immediately or was queued.
Treat turnId as opaque: the derivation is a correlation guarantee, not a
format you may parse. And remember the two no-run exits when awaiting a
pre-minted turn: reclaim folds
turn/unqueued with
no completion event ever, and a failed launch folds a failed
turn/completed with
no started event.