config.toml, plan documents, tool documents, and the data directory. Each carries a file version — an integer, separate from the binary’s version and from each other — that moves every time that file’s schema changes.
graph version prints the binary version and the file version each kind is written at:
config 3 (reads 2-3)), and graph version --json reports oldest and current under fileVersions.
The compatibility promise
- Every schema change bumps the file version. A new optional key, a removed key, a rename, a changed shape: all of them. The version is the schema’s generation number, so two binaries that agree on it agree on every key.
- A binary reads every version in its window, and nothing outside it. Older files inside the window are migrated in memory as they load; the file on disk is not modified until you run a
migratecommand. Upgrading graph within a window never requires touching a file first. - A file outside the window fails, by name, before the schema is consulted. The message says which file, which version it found, which versions this binary reads, and what to do: upgrade graph for a newer file, or migrate an older file with a release that still reads it. Never an “unknown field” for a key that is correct.
- The window narrows only at a major release, announced one minor release ahead.
graph config checkreports files that would fall outside it.
What a bump carries
A migration is a forward-only step from version N to N+1. The binary carries the chain for its whole window, so a version 2 file on a binary that writes version 4 runs two steps; each step is verified by a frozen fixture pair in the repository (the version N file and the version N+1 file it must produce), and the fixtures for every version in the window must still load on every release.
Files graph writes always carry the current version. A plan re-saved from a newer workbench therefore carries the newer number even if it uses nothing new, and an older binary outside that window refuses it — the number is the contract, not the keys the file happens to use.
Config
Both layers are migrated independently, before they merge: the global file and the project file may sit at different versions. Theversion key itself never reaches the merged config.
migrate preserves comments and layout; it writes version as the first key and leaves everything else where it was. A file already at the current version is left untouched. On a terminal, any command that loads a config file older than the current version prints a one-line hint to run migrate; non-interactive runs (CI, graph mcp serve) stay silent, since the in-memory upgrade already happened.
Plans and tools
A plan file outside the binary’s window is skipped with a diagnostic and stays out of the catalog, exactly like a file that fails to parse:plan list --json reports it under skipped, and naming it (plan run, plan show) reports the version mismatch rather than “no plan named”. A tool file outside the window fails the tool catalog by name.
plan new, plan draft, plan show — always carry the current version.
Every plan and tool file is read strictly: an unknown key anywhere in a step or a tool document is a load error naming the key, never silently ignored. A misspelled timeout_secs used to be dropped on the floor; now the file is skipped and the diagnostic says why.
Data directory
FileStore writes <data_dir>/FORMAT holding the store version on first open and refuses to open a directory whose marker is outside its window. That refusal matters more here than anywhere else: thread messages are appended under a lock without re-reading the file, so an older graph must never write into a layout it does not understand. Records inside the directory carry their own version field for per-record migration.