Skip to content

Durable mutation outcomes

mutation_outcome/v1 publishes what happened to each protected write independently of assistant narration. Records are derived on read from the session’s mutation ledger; there is no second store. Replay refusal, identity and settlement come only from the ledger, so history, live events and replay can never disagree with what dispatch enforces.

{
"schema": "mutation_outcome/v1",
"logical_turn_id": "…",
"mutation_id": "…",
"tool": "mcp__dashboards__create",
"status": "completed",
"resource_id": "server-confirmed-id",
"url": "https://app.example.com/d/server-confirmed-id",
"title": "Service overview"
}
Ledger status Published status
completed completed
failed failed
outcome_unknown, execution_started outcome_unknown
approved after recorded intent (cancelled before the router, or reconciled with completed=False) not_executed
approved, never started not published: the router was never entered

resource_id, url and title come only from host metadata attached with Engine.attach_mutation_metadata (id, url, title), never from tool output. They are null until the host attaches them.

  • resource_id is published unchanged or not at all: values over 256 UTF-8 bytes, with control characters, or resembling credentials are withheld.
  • url keeps only http(s) scheme, host and path; credentials, query and fragment are dropped. URLs over 2048 bytes are withheld.
  • title drops control characters, redacts credential patterns, is truncated to 512 UTF-8 bytes, and is withheld if it is (or truncates to) structured JSON.
  • A serialized record never exceeds 16 KiB.

docs/contracts/mutation_outcome_v1.json holds the limits and ledger-entry → record examples. Consumers should validate against it.

Starting in 0.23.30, a settled timeout may include an optional reason: pre_dispatch_deadline with not_executed, or execution_timeout with outcome_unknown. This is projected from the ledger, never inferred from error text. A fresh attempt or authoritative reconciliation clears the old reason. Older records and other failures omit it. Approval expiry precedes mutation intent and remains an approval/tool event (approval_outcome: timed_out).

SessionManager.mutation_outcomes(session_key) returns every retained outcome, oldest logical turn first. The UI API exposes the same list:

GET /api/mutation-outcomes?session_id=<key>
→ {"mutation_outcomes": [ … ]}

History follows ledger retention: it survives restart, is hidden when the conversation expires, and is deleted with the session.

After an outcome is durably settled, the engine calls Adapter.on_mutation_outcome(outcome, mutation_revision=n). The API adapter emits it as a mutation_outcome run event. Refusals of a recorded mutation emit nothing: a redelivered request yields no new outcome. Delivery is best effort; adapter errors never change settlement.

mutation_revision is the replay cursor. Each logical turn keeps a durable revision counter in the ledger; every change a consumer can see (recorded intent, settlement, reconciliation, host metadata) stores the next revision on its entry. An entry is replayed under the revision of its latest change, so a client that has seen revision N has seen every change up to N, even when mutations settle out of creation order or change after settling. Live events on one stream are emitted in revision order; in-flight intent is not emitted live, only its settlement.

GET /api/mutation-events?session_id=<key>&logical_turn_id=<id>[&after=<n>]

returns a finite text/event-stream snapshot of the outcomes changed after the cursor (also accepted as Last-Event-ID), oldest revision first, each framed with id: <n>. Every outcome_unknown outcome is also replayed after any cursor until it is settled or reconciled: a live stream can deliver revision N while an earlier write is still in flight, and if that write’s settlement fails or the process stops, no later revision would announce it. Clients should upsert by mutation_id. Each event carries the entry’s current projection, so a reconciled outcome_unknown reappears as completed or not_executed, and late host metadata reappears with its resource_id. Entries recorded before revisions existed appear only in a full replay. Errors: 400 for an invalid cursor, 404 for an unknown session or logical turn, 409 for a cursor past the turn’s latest revision.