The browser showed JFK. The database already contained BOS.
That was the state of a synthetic trip after I deliberately hid the successful response to a WebMCP update. The server had committed the change, but the React editor could not prove it. Retrying the PATCH would have confused “I did not receive the response” with “the server did not apply the command.”
The safer next operation was not another write. It was a lookup of the original command.
I maintain Trip Workbench, the public React and TypeScript demo used here. The recorded trace ran with native WebMCP in HeadlessChrome 153.0.8010.0, Puppeteer 25.16.0, and a disposable PostgreSQL-compatible database. A script chose the calls; no language model selected this recovery path.
The failure is “unknown,” not “failed”
The starting draft was revision 2:
- destination:
JFK - purpose:
Customer workshop - status:
draft
The test called update_trip_draft with a stable command ID:
{ "draftId": "<open-draft-id>", "expectedRevision": 2, "commandId": "audit-lost-response", "patch": { "destination": "BOS" } } After the server processed the PATCH, the harness dropped its HTTP 200 response and temporarily hid the corresponding receipt. An independent query already showed revision 3 and destination BOS. The editor still showed revision 2 and JFK.
That placement is the point of the test. Aborting before dispatch proves that no request left the browser. Dropping a response after commit produces an uncertain outcome: the client cannot honestly label the mutation either successful or failed.
Browser Server Receipt store | PATCH cmd-123 | | |------------------->| commit revision 3 | | |----------------------->| store cmd-123 | response lost X | | | | | | GET receipt cmd-123| | |-------------------------------------------->| |<---------- confirmed revision 3 ------------| The application returned OUTCOME_UNKNOWN with the original command ID and a recheck instruction. That is not a polished error label; it is a safety boundary.
Give a mutation two identities
The update carries two identifiers with different jobs:
-
expectedRevisionidentifies the saved version the caller intended to change. -
commandIdidentifies the logical mutation whose result may need recovery.
A revision conflict means the starting version was stale. A missing response means the caller does not know whether its command committed. Those states require different recovery paths.
Trip Workbench stores a receipt in the same server-side workflow as the mutation and exposes an authorized lookup:
GET /api/trips/:draftId/commands/:commandId The recheck_trip_update WebMCP tool can query only the command currently attached to the editor’s unresolved outcome. It is not a general receipt browser, and it does not send another PATCH.
{ "draftId": "<open-draft-id>", "commandId": "audit-lost-response" } Once the harness stopped hiding the receipt, the recheck confirmed that the original update moved the draft from revision 2 to 3.
This demonstrates idempotent recovery for this application contract. It is not a claim of universal exactly-once delivery across every distributed system.
Preserve the human edit made during uncertainty
While the receipt was unavailable, the test changed the visible purpose to:
Human edit while the receipt was pending
Recovery now had two truths to reconcile:
- The server had saved BOS and retained the previously saved purpose.
- The person had made a newer local edit that had never been saved.
Replacing the form with the recovered server snapshot would have destroyed the user’s work. Treating the local value as saved would have lied about persistence.
The coordinator therefore keeps the original mutation context:
type PendingOutcome = { draftId: string; commandId: string; kind: "update" | "submit"; sessionGeneration: number; editorGeneration: number; ctx: AsyncOpContext; }; AsyncOpContext retains the saved and local snapshots from dispatch time plus the edit version. The recheck reuses those snapshots. Capturing a fresh baseline only when recovery starts would erase the distinction between “present before the PATCH” and “typed after the response disappeared.”
The recovered state was intentionally split:
| Observation | Result |
|---|---|
| Saved destination | BOS |
| Saved revision | 3 |
| Saved purpose | Customer workshop |
| Visible purpose | Human edit while the receipt was pending |
| Editor | Unsaved changes |
| PATCH requests | 1 |
| Matching receipts | 1 |
A later get_trip_draft returned the saved purpose and hasUnsavedChanges: true. That gives an agent enough information to avoid overwriting the person’s pending work.
Assert three surfaces, not one
The strongest part of this test is not the WebMCP response alone. The harness compares three independent surfaces:
assert.equal(toolResult.toRevision, 3); assert.equal(visible.destination, "BOS"); assert.equal(visible.localPurpose, "Human edit while the receipt was pending"); assert.equal(visible.saveState, "Unsaved changes"); assert.equal(database.destination, "BOS"); assert.equal(database.purpose, "Customer workshop"); assert.equal(patchRequests, 1); assert.equal(matchingReceipts, 1); Counting receipts alone does not prove that the browser avoided a second write. Counting requests alone does not prove that the first request committed. A UI-only assertion could miss the persisted result entirely.
Harden every exit from recovery
The original trace at commit e711e20 exposed adjacent failure modes: a hung receipt request could outlive the nominal deadline, and a 401 could leave the editor marked as saving.
The later reliability pass at 0691767 adds bounded receipt lookups and releases the matching operation on authentication failure and terminal exits. It also distinguishes uncertain updates from uncertain submissions so the review screen can recheck a POST without issuing another one.
Chrome’s current WebMCP imperative API supplies an AbortSignal to tool handlers and supports cancellation of execution. That signal is necessary, but application code must still decide what cancellation means after a remote server may have committed.
The design rule I kept from this exercise is simple:
When a mutation response disappears, recover the original command before deciding whether another mutation is safe.
The useful outcome was not merely “BOS appeared.” It was one confirmed saved revision, one request, one receipt, and one human edit that remained visibly unsaved.
