015 — checkpoint_resume returns a successful, machine-readable absence result
Status: Accepted — Date: 2026-09-12
Context
Section titled “Context”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 carrystructuredContent. _metais not a fallback: it is not the validated field, and clients that enforce the schema reject the result before any_metais read.- Emitting
structuredContenton 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.
Decision
Section titled “Decision”Add an optional on_missing parameter to checkpoint_resume, enum
"error" | "empty", default "error":
default/"error": unchanged from v0.19.1 — text-onlyisError: true, historical prose, nostructuredContent."empty": success (isErrorabsent/false) withstructuredContent{"found": false, "agent_id": "<id>"}(wire typecheckpointResumeEmpty,foundwithoutomitemptysofalsecannot vanish) and textNo checkpoint saved for agent "<id>" yet.- Real failures (storage, daemon, corrupt records, uninitialized store) stay
prose-only
isError: truein both modes; only theErrNoCheckpointsentinel 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.
Real-client release gate — 2026-10-02
Section titled “Real-client release gate — 2026-10-02”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.
Consequences
Section titled “Consequences”- 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 withgithub.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.Foundmust not carryomitempty, and a wire test asserts"found": falseis present in the serialized response.
Alternatives rejected
Section titled “Alternatives rejected”- Typed error
structuredContent— the #117 payload ({"error": "not_found", "agent_id"}withisError: 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
structuredContentis rejected outright by the reviewed client. _metamarker — rejected: not schema-validated, not surfaced to callers, and rejected before it can be read whenstructuredContentis missing.- A companion
checkpoint_statustool — 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
emptywithout 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.
Reversal condition
Section titled “Reversal condition”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.