Skip to content

015 — checkpoint_resume returns a successful, machine-readable absence result

Status: Accepted — Date: 2026-09-12

Since #117, checkpoint_resume with no checkpoint returns a text-only isError: true result. That fixed a real defect — the older typed error payload ({"error": "not_found", "agent_id"}) did not conform to the tool’s success-only outputSchema, and strict clients rejected the entire response — but it left absence undetectable without parsing prose. An agent booting with checkpoint_resume cannot programmatically distinguish “you have no saved state” from “the store is broken”, which is exactly the distinction a session start needs.

The ADR-013 amendment deferred a machine-readable absence result to a separate contract decision. This is that decision.

Client review evidence. OpenCode 1.18.29’s bundled MCP client uses ajv’s compile and validates structuredContent against the tool’s declared outputSchema whenever it is present, regardless of isError. It also rejects a successful (non-error) result that omits structuredContent for a tool that declares an outputSchema (“has an output schema but did not return structured content”). Consequences:

  • A text-only success result is not viable for any tool with an outputSchema; a machine-readable absence result must carry structuredContent.
  • _meta is not a fallback: it is not the validated field, and clients that enforce the schema reject the result before any _meta is read.
  • Emitting structuredContent on the error path (the #117 design) must satisfy the advertised schema — which is why absence could not simply stay an error with a typed payload.

Add an optional on_missing parameter to checkpoint_resume, enum "error" | "empty", default "error":

  • default / "error": unchanged from v0.19.1 — text-only isError: true, historical prose, no structuredContent.
  • "empty": success (isError absent/false) with structuredContent {"found": false, "agent_id": "<id>"} (wire type checkpointResumeEmpty, found without omitempty so false cannot vanish) and text No checkpoint saved for agent "<id>" yet.
  • Real failures (storage, daemon, corrupt records, uninitialized store) stay prose-only isError: true in both modes; only the ErrNoCheckpoint sentinel selects the absence result.

The output schema becomes a root-object oneOf union: the generated checkpointResumeResult branch (built mechanically, never hand-written) plus an absence branch {"type":"object","additionalProperties":false,"required": ["found","agent_id"],"properties":{"found":{"type":"boolean","enum":[false]}, "agent_id":{"type":"string"}}}. The union root declares no properties/required/additionalProperties of its own — a root-level additionalProperties: false applies to every branch and rejects both payloads.

Staged migration. "empty" is opt-in in v0.20.0, whose default remains "error". Changing the default to "empty" in v0.21.0 is conditional on a successful real OpenChamber/OpenCode smoke and prior-minor notice in the released changelog and docs/api-stability.md. If the union is rejected, keep "error" as the default and defer the change. Explicit "error" remains accepted as the legacy behaviour throughout v0.x; callers depending on absence being an error must select it before any default change.

Fallback if a real client rejects oneOf. Collapse the output schema to a single permissive object declaring all six keys (id, created_at, state, message_count, found, agent_id), none required, additionalProperties: false, while keeping structuredContent on both paths. The wire payloads do not change; only the schema’s validation strength does. The release gate is a real OpenChamber/OpenCode smoke run against this schema — not just the contract tests — and it must pass before v0.20.0 ships, because the union is validated on every structured result from that release.

Passed with OpenCode CLI 1.18.18 on Windows. The installed client ran opencode run --pure --format json against a locally built v0.20.0 candidate from commit e29a761f900eac74faa72b2afaf3e2111919512a. Release metadata was being prepared separately; the MCP implementation was unchanged from that commit. The stdio handshake independently identified the client as opencode / 1.18.18 and negotiated MCP 2025-11-25.

This exercised OpenCode’s actual tool discovery, dispatch and result validation. A deterministic OpenAI-compatible fixture on 127.0.0.1 supplied tool-call instructions, avoiding any paid model or credentials. OpenCode configuration, data, cache, state, home and the Graymatter store were isolated under a temporary directory. Project configuration discovery, external skills, plugins, sharing, update checks and model-catalog fetching were disabled; only the fixture provider was enabled. Graymatter ran with --no-daemon --dir <temporary-store> mcp serve, without provider keys and with its Ollama probe directed at a closed loopback port. No user settings or existing memory stores were changed.

The recorded tools/list response contained all seven tools, including checkpoint_resume with the root-object oneOf schema described above. The same OpenCode session then performed these operations:

Case MCP result OpenCode result
Missing checkpoint, on_missing: "empty" Successful structuredContent with found: false and the requested agent_id Tool completed
Save a synthetic checkpoint Successful structured save result Tool completed
Resume that checkpoint, on_missing: "empty" Successful structured result with the saved ID, creation time and exact synthetic state Tool completed
Missing checkpoint, parameter omitted Text-only isError: true, no structuredContent Historical tool error surfaced
Missing checkpoint, explicit on_missing: "error" Text-only isError: true, no structuredContent Historical tool error surfaced
Resume the saved checkpoint, parameter omitted Same successful checkpoint payload Tool completed

Negative control: a separate run used a transparent stdio recorder that changed only the absence response’s found value from false to true. OpenCode rejected it with MCP error -32602, reporting that structured content did not match the output schema, including the enum violation and failure to match exactly one oneOf branch. This confirms that the valid cases passed the real client’s schema validator rather than bypassing it. The production server and its schema were not modified for this control.

Both CLI runs exited successfully after consuming the expected tool results; the negative control itself was correctly reported as a tool error. Evidence was captured as valid-mcp.jsonl, valid-events.jsonl, invalid-mcp.jsonl and invalid-events.jsonl in the temporary smoke directory. The tested binary’s SHA-256 was f1fa958304e3444cadc06628c703c97b725b9a00472c80364c27123411b6793f.

The v0.20.0 real-client gate is satisfied for this OpenCode version; the permissive schema fallback is unnecessary. OpenChamber’s graphical surface and other client versions were not tested. This evidence does not change the v0.20.0 default: on_missing remains "error", and any later default change still requires the migration notice described above.

  • A session-start caller can opt into {"found": false} and stop treating “no checkpoint yet” as a failure, without a prose parser.
  • Existing callers are unaffected by construction: the default path is byte-identical to v0.19.1, and the union schema accepts the success payload unchanged.
  • The union root means structured_contract_test.go’s hand-rolled key/type checks cannot validate this tool; checkpoint resume payloads are validated with github.com/santhosh-tekuri/jsonschema/v6 (already in the module graph via mcp-go, promoted to a direct test dependency).
  • The zero-value trap is pinned twice: checkpointResumeEmpty.Found must not carry omitempty, and a wire test asserts "found": false is present in the serialized response.
  • Typed error structuredContent — the #117 payload ({"error": "not_found", "agent_id"} with isError: true). Rejected: it violates the success-only output schema, which is the defect #117 fixed.
  • Text-only success — rejected: a schema-bearing tool’s successful result that omits structuredContent is rejected outright by the reviewed client.
  • _meta marker — rejected: not schema-validated, not surfaced to callers, and rejected before it can be read when structuredContent is missing.
  • A companion checkpoint_status tool — rejected: adds an eighth tool and a second round trip to answer a question the resume call already has, and clients select tools by description, so “call this first” is a request the protocol cannot enforce.
  • Direct flip to empty without notice — rejected: turning absence from an error into a success is observable behaviour; the v0.x stability promise requires a prior-minor notice and the staged path gives real deployments a release to opt in first.

The real-client gate must pass before v0.20.0 ships: strict clients validate the output schema on every structured result. If the pre-release OpenChamber/OpenCode smoke rejects or mis-handles oneOf, apply the permissive single-object fallback above and repeat the smoke before publishing v0.20.0. Record the client/version evidence in this ADR, keep "error" as the default, and defer the default change.

Later field reports of schema incompatibility also suspend any default change until the fallback passes a real-client smoke. A default change requires affirmative client evidence and prior-minor notice; absence of reports alone is insufficient. If a later default change produces reports of behavioural breakage that opt-in adoption did not reveal, restore "error" as the default for the remainder of v0.x and reopen the contract question with the breakage evidence.