docs/adr/0060-progress-as-minted-checkpoints.md

ADR 0060: Progress survives as minted checkpoints, never a mutated identity

Status: accepted (James, 2026-07-25). Implemented in the GBA emulator integration and the GBA MCP server, with the fail-closed paths covered by tests.

Current status (2026-08-19)

Checkpoint identity and verification remain unchanged. Under ADR 0129, Clankie's play and each GBA MCP process operate separate cores and checkpoint scopes; save/load authority comes from the owning runtime, not possession of a shared body.

Context

Every boot of the real core loads a pinned savestate and verifies it against the scenario fixture's digest, failing closed on mismatch. Play progress persists through separately minted checkpoints. The configured fixture remains immutable; the in-game save writes to process-local flash until the checkpoint serializer captures the complete core state.

The naive fix — write the current state over the configured savestate — would break the model it lives inside: the fixture's digest would fail to match, and "the pinned savestate" would quietly stop meaning anything.

Decision

A save mints a sibling identity

writeGbaCheckpoint captures the serialized core state into an operator-local checkpoint directory alongside two documents:

  • a receipt recording the checkpoint id, optional label, capture time, overworld position, and the digests of the savestate, ROM, and core wasm the running core verified at boot;
  • a companion scenario — the booted route scenario with only its savestate identity replaced (savestateId: checkpoint:<id>, the new digest). It parses under the same schema and boots through the same fail-closed loader, so a checkpoint is a first-class pinned identity, not an exception to the rule.

The pinned fixtures stay frozen. Nothing ever overwrites an existing identity; a checkpoint is always a new sibling. Savestate bytes stay operator-local exactly like the ROM — receipts, evidence, and tool results carry digests only. Receipts also name the environment and journey when the minting runner knows them, so an operator can identify saves without opening their state bytes.

A load verifies everything before touching the core

readGbaCheckpoint refuses ids that are not directory basenames, receipts that do not name their own directory, checkpoints taken from a different ROM or core build, and savestate bytes that fail their recorded digest. Only then does the core restore, resetting its derived battle bookkeeping — that bookkeeping described the replaced timeline. Logical frame and input counters keep counting: they order evidence within the process run, and restoring RAM does not rewind what already happened here.

Save and load are driving

The MCP surface publishes both as lease-gated tools. gba_emulator_save_state captures the body's complete state — more than observation exposes — and gba_emulator_load_state rewrites the body's whole world, so both are gated by possession exactly like acting (ADR 0053). Load with no id lists what exists instead of guessing. Both are absent on the deterministic double, whose determinism is its identity: there is no state to serialize that the scenario does not already pin.

Consequences

  • Hours of unreplayable free play (ADR 0049) outlives the process: save a checkpoint, and a later boot points CLANKIE_GBA_SAVESTATE_PATH and CLANKIE_GBA_SCENARIO_PATH at the checkpoint's pair. Mid-session, a possessor can restore without restarting.
  • The determinism anchors hold. Session specs bind to whatever scenario booted them, checkpoint or fixture, through the unchanged validateScenarioBinding.
  • The companion scenario's route fields (map, start, target) describe the original fixture's route, not where the checkpoint is taken; the receipt records the actual position. A checkpoint scenario is a boot anchor, not a route to replay — running the deterministic route drivers from one is not a supported use.
  • Evidence emitted after a mid-session load still cites the boot savestate in its determinism anchors, which stays true — that is where this session started. The load itself is visible in the tool result and the watchers' frame stream.
  • Deletion is operator-only. /saves in the local TUI lists validated receipt directories and requires a second confirmation before removing exactly one; malformed ids, mismatched receipts, and symlinked directories are refused.