Cartographer
Overview
Cartographer pins named bookmarks into the mission journal — checkpoints. Each checkpoint stores a JSON state snapshot plus a journal cursor (the ID of the last entry at the time the checkpoint was made). The two together let an operator answer “where were we at 14:32?” and “what happened since?” — first by reading the snapshot, then by replaying the journal entries past the cursor. Crucially, restore is advisory: it produces a report (checkpoint + divergence), not a database mutation. Cartographer never rewrites a mission’s history — that’s the Crew Journal’s job, and journals are append-only by design. When the operator actually wants to “go back and try a different path”, the right operation is fork: spawn a fresh mission whose journal starts from the parent’s checkpoint cursor. The new mission has its own ID, its own row, its own audit timeline; the original mission keeps running (or stays paused) without contamination. This is the same model Git’s branch-from-commit takes — Cartographer is the mission-level equivalent, and the fork_of column on the new mission’s row is the equivalent of a parent SHA pointer.
The subsystem itself is small: a single table (checkpoints), one handler file (internal/api/cartographer_handler.go), and a thin CLI wrapper. There’s no scheduler, no background worker, no extra ports — checkpoints are written by explicit operator action and read on demand. The four operations are List (enumerate bookmarks for a mission), Restore (advisory diff against the journal), Fork (spawn the new mission anchored at the cursor), and Delete (drop a checkpoint; forks that reference it via fork_of are orphaned, not cascaded). Workspace-scope and crew-scope access checks ride on the same RBAC the rest of the API uses.
From a checkpoint you can:
- List — see all bookmarks in a mission.
- Restore (advisory) — get the checkpoint plus the list of journal entries that have diverged since. No mission state is mutated.
- Fork — spawn a new mission anchored at the checkpoint’s cursor. This is the concrete “go back and try a different path” operation.
- Delete — remove a checkpoint (forks that reference it via
fork_ofare orphaned, not cascaded).
When to use it
Checkpoints are cheap to mint and cheap to ignore — favour creating one whenever there’s a meaningful state worth being able to compare or branch from later. The canonical triggers:- Right before a risky agent step. About to let the agent run a
terraform applyor a destructive migration?crewship checkpoint create --mission MIS-42 --label "pre-apply"first. If the step goes sideways,restoreproduces a diff against where things were, andforklets you retry from clean state in a parallel mission. - A/B experimentation on prompts or tooling. A mission has reached a sensible decision point and you want to try two different strategies (different system prompts, different tool sets). Checkpoint the current state, then
forkonce per variant — each fork gets its own mission ID, its own journal, and its own audit timeline. - Mid-mission debugging — “what changed since 14:32?” When an operator asks why a mission’s behaviour drifted, the
restoredivergence list is the answer: it enumerates exactly which journal entries posted after the checkpoint was taken. Beats grepping the timeline. - Long-running missions where you want milestone bookmarks. A mission running across several hours of agent work benefits from a periodic checkpoint at each “task complete” boundary. Future operators (or future-you) can jump to a specific stage without scrolling the whole journal.
- Post-incident investigation. Suspected misbehaviour? Checkpoint immediately — the snapshot freezes the visible state and the cursor pins what the agent saw up to that instant. Subsequent investigation can fork from the checkpoint into a sandbox mission for safe replay without disturbing the real one.
Key concepts
Glossary — checkpoint, cursor, snapshot, fork, and friends
Glossary — checkpoint, cursor, snapshot, fork, and friends
| Term | What it means here |
|---|---|
| Checkpoint | One row in the checkpoints table — (id, mission_id, label, journal_cursor, state_snapshot, fork_of, …). Cheap to mint (the snapshot is a single INSERT); persisted until explicitly deleted. The user-facing object every other operation hangs off. |
| Journal cursor | The journal_cursor column. Holds the entry ID (not timestamp) of the last journal entry visible when the checkpoint was captured. Used as an anchor — everything posted after this ID is “drift” by definition. |
| State snapshot | The state_snapshot column. Plaintext JSON capturing mission status, agent positions, pending tool calls, and anything else the orchestrator’s Capture walks out of the journal. Plaintext is deliberate — checkpoints are operational metadata, not credentials, and queryable JSON beats opaque blobs. |
| Mission | The parent that owns the checkpoint via mission_id. Workspace and crew scope are inherited from the mission row, so checkpoints can’t escape their tenancy by accident. |
Capture() | The internal function (cartographer.Capture(ctx, db, missionID)) that walks the mission’s journal entries and produces (snapshot, cursor). Pure function — no side effects, idempotent given the same input journal. |
| Advisory restore | POST /checkpoints/{id}/restore returns (checkpoint, divergence_list) without mutating anything. The mission keeps running. The UI shows the diff and lets the operator decide whether to fork. |
| Divergence list | The warn_divergence field on a restore response — an enumeration of journal entry types (mission.status_change, exec.command, …) that posted after the checkpoint’s cursor. Long list = the mission has moved on; consider a fork rather than trying to reconcile. |
| Fork | POST /checkpoints/{id}/fork. Atomically: creates a fresh mission with a new mission_id, captures a new checkpoint in that mission at the source checkpoint’s cursor, sets fork_of on the new checkpoint to the source, and writes a fork.created journal entry to the new mission. |
fork_of | The parent-pointer column on a forked checkpoint. The mission-level analogue of a Git parent SHA. ON DELETE SET NULL — deleting the ancestor checkpoint orphans the child rather than cascading destruction of forked missions. |
| Orphaned fork | A forked checkpoint whose fork_of was set to NULL because the ancestor got deleted. The forked mission itself is unaffected — it has its own ID, its own journal, its own work. Only the genealogy pointer is lost. |
| Workspace isolation | Enforced at the handler (mission must belong to the caller’s workspace) AND at the package layer (cartographer.Get / Delete re-check). Cross-tenant IDs return 404 with the same shape as “not found” — by design, to prevent enumeration. |
| Journal entry types | Three checkpoint-emitted types: checkpoint.created (info, on Create), checkpoint.restored (info, on advisory Restore — divergence list in payload), fork.created (notice, on Fork — written to the new mission’s journal, not the parent’s). |
Usage
Thecrewship checkpoint command group covers the four operations end-to-end. Each subcommand maps 1:1 to the HTTP endpoint documented under API reference. The most common loop is bookmark → inspect → fork.
Create a checkpoint at the current cursor
--mission has no journal entries yet, the call returns 409 (mission has no journal entries to anchor a checkpoint); run the mission at least one step first.List bookmarks (newest-first)
GET /api/v1/missions/{missionId}/checkpoints (server-side limit 1–200, default 50) and renders the response as a tab-aligned table.Restore (advisory — does not mutate the mission)
(checkpoint, divergence_list) from the server and prints it. The mission keeps running — no state is rewound. Use the output to decide whether to keep going or fork.Fork into a new mission
internal/api/cartographer_handler.go for the handler source of truth.
Examples
Pre-deploy bookmark, fork on regression
The missionMIS-42 reaches a green build of the staging branch. Before letting the agent attempt the production deploy step, you bookmark:
deploy-prod. Logs go red — the deploy hit an unrecognized AWS region and the agent is now stuck retrying. You don’t want to roll the mission backward (the agent’s context already knows the failure), but you do want a clean parallel attempt:
MIS-43 opens at the green-staging cursor. You hand it a fresh hint (“use eu-west-1 explicitly”) and let it retry. MIS-42 keeps running its diagnostic in parallel — useful for the postmortem.
A/B prompt experimentation
You’ve reached a decision point inMIS-88: should the agent draft the blog post in a casual or formal tone? Rather than picking one, checkpoint and fork twice:
MIS-89 and MIS-90 each get their own journal and their own agent runs from the same starting point. Once both drafts exist, compare side-by-side in the UI; pick the winner; the loser’s mission row stays around as evidence of the alternative explored.
Post-incident forensic snapshot
An operator notices the agent inMIS-101 just sent a suspicious tool call. Snapshot immediately — before anything else changes:
MIS-102 is the investigation playground. Replay the suspicious tool call there with extra logging, try variants, prove the bug — none of it touches MIS-101, whose timeline must stay pristine for the audit log. When the postmortem closes, both missions get linked from the incident document via their IDs; the fork_of pointer on MIS-102’s checkpoint proves the parentage.
Schema
Restore is read-only
POST /api/v1/checkpoints/{id}/restore returns:
Fork is the action
POST /api/v1/checkpoints/{id}/fork with {"label": "experiment-1"} creates:
- A new mission.
- A fresh checkpoint in that new mission anchored at the source checkpoint’s cursor, with
fork_of = <source checkpoint id>. - A
fork.createdjournal entry.
Create
POST /api/v1/missions/{missionId}/checkpoints with optional {"label": "..."} captures the current mission state + cursor:
cartographer.Capture(ctx, db, missionID)walks the journal for that mission and produces(snapshot, cursor).- If the mission has no journal entries, Create returns 409 Conflict (
mission has no journal entries to anchor a checkpoint) — a checkpoint without anchor can’t diverge.
API reference
Full request/response schemas live at/api-reference/checkpoints. The handler source is internal/api/cartographer_handler.go — small enough to read end-to-end. The endpoints, grouped by mission-scope vs. checkpoint-scope:
Mission-scoped (mission ID in path)
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/missions/{missionId}/checkpoints | List newest-first. Query param: limit (1–200, default 50). |
POST | /api/v1/missions/{missionId}/checkpoints | Create checkpoint at current cursor. Body: {"label": "…"} (optional). Returns 409 if mission has no journal entries. |
Checkpoint-scoped (checkpoint ID in path)
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/checkpoints/{id} | Full checkpoint detail — snapshot, cursor, fork_of, created_by/at. |
POST | /api/v1/checkpoints/{id}/restore | Advisory. Returns (checkpoint, journal_cursor, warn_divergence[]). Never mutates. |
POST | /api/v1/checkpoints/{id}/fork | Spawn new mission + fork checkpoint. Body: {"label": "…"} (optional). Returns {new_mission_id, new_checkpoint_id}. |
DELETE | /api/v1/checkpoints/{id} | Delete checkpoint. Orphans children (sets their fork_of to NULL); does NOT cascade to forked missions. |
cartographer.Get / Delete re-check workspace). Cross-tenant IDs return 404 with the same shape as “not found” — the same response a missing ID would produce — by design, to prevent existence enumeration.
The CLI binary (crewship checkpoint …) maps 1:1 onto these endpoints; see Usage for invocations and /cli/checkpoint for full flag reference.
CLI
crewship checkpoint.
Journal entries emitted
| Operation | Entry type | Severity |
|---|---|---|
| Create | checkpoint.created | info |
| Restore | checkpoint.restored | info (advisory; divergence list in payload) |
| Fork | fork.created | notice |
Common pitfalls
- Restore is advisory — never a rewind. The endpoint name is misleading if you read it literally:
restorereturns a diff, not a time-machine. Crewship deliberately doesn’t unwind sidecar state, container filesystems, or external side effects (commits pushed, messages sent, containers created) that fired after the checkpoint — partial rewind has no safe general form. If you actually want to “go back”, fork; the divergence list tells you whether you should. - Orphaned forks are deliberate. Deleting a checkpoint that other checkpoints reference via
fork_ofsets those pointers to NULL — it does NOT cascade to the forked missions. A fork is a real mission with its own work; deleting the ancestor checkpoint should never destroy descendant work product. If you want the forked missions gone too, delete them explicitly first. journal_cursoris an entry ID, not a timestamp. Pagination over the journal uses compound(ts, id); cartographer uses justidbecause the cursor is an anchor, not a range pointer. Trying to “find the checkpoint nearest 14:32” via the cursor field is a category error — query the journal for the entry ID at that timestamp first, then use that to anchor.- A divergence list that’s grown long is a signal to fork, not investigate. Once
warn_divergenceis more than a handful of entries, the snapshot’s state probably no longer reflects the live mission’s external reality. The checkpoint is still useful as a starting point for a fork; trying to use it as a baseline for “what should the mission look like now” gets unreliable fast. - Cannot checkpoint a mission with no journal entries.
Createreturns 409 (mission has no journal entries to anchor a checkpoint) if you try. The first journal entry must exist before a checkpoint can pin to anything — run the mission for at least one step first. - Cross-tenant checkpoint IDs return 404, not 403. A checkpoint ID from another workspace produces the same response as a non-existent ID — by design, to prevent existence enumeration. If you genuinely thought you owned a checkpoint and got 404, double-check the workspace context the CLI / API call is running under (the
CREWSHIP_WORKSPACEenv var or the session cookie’s workspace claim). - Forks live in the source workspace + crew. A fork doesn’t escape to a parent workspace or unrelated crew — it’s a sibling mission in the same scope. If you need cross-workspace promotion (e.g., “this experiment graduated, copy to prod”), that’s a separate workflow involving Backup & Restore
--as-workspace, not a fork.
Related
- Crew Journal — the canonical append-only event stream that checkpoints anchor into via
journal_cursor. Read this first to understand what “the cursor points at” actually means. - Orchestration — the mission lifecycle (start → run → pause → complete) that checkpoints bookmark; fork creates a fresh mission lifecycle, not a thread on the existing one.
- Activity — the live operator surface that surfaces checkpoint creation and fork events on the canvas as they happen.
- Keeper — the credential vault to use for tool inputs so the plaintext
state_snapshotcolumn never captures raw secrets. - Backup & Restore — for the “promote this fork to a different workspace” workflow that checkpoints/forks alone don’t cover.
crewship checkpointCLI reference and Checkpoints API for full HTTP schemas.