ADR-0030 Architecture decision record
A snapshot names the Machine it was written under
ADR-0007 persists a run's snapshot so a restarted Orchestrator resumes it. ADR-0008/ADR-0019 publish the Instance's Assembly to an Orchestrator whose volume outlives every publish (ADR-0069). And a persisted run is matched to its workflow by filename stem: by name inside the Assembly its snapshot names, and, for a run that no Assembly started (an in-process test host), by name in the registry.
Those three facts compose into a hole. Park a run at a Gate, edit workflows/task.ts, jr2 up. The name still resolves. The def found under it is a different Machine. Nothing checks, and — this is the part that decides the shape of the fix — xstate does not validate a restored state value. A snapshot whose value names a state the new chart does not have does not throw: createActor accepts it and the run starts with value: undefined, carrying on from nowhere. There is no downstream check to fall back on. Either restore refuses, or nothing does.
Decision
Every save stamps the shape of the Machine that wrote it; every restore compares before interpreting. RunBlob gains machine, a 12-hex digest — the same width jr2 up's content hash uses, and for the same reason: it is read by people in error messages more often than by code. A mismatch is refused before reconcile, because proving a Sandbox is alive for a run that cannot be read is wasted work.
The digest covers restorability, not behavior. It is built from machine-doc.ts's traversal — already deterministic (states sorted by order), JSON-pure, provider-independent, and descending into invoked and spawned child machines — projected down to the facts a snapshot has to agree with:
- state ids, keys, types, nesting, and which child is
initial; - each state's invokes by
idandsrc(the id is the key in the persistedchildrenmap, so re-attach depends on it — the most easily missed of the three, and a reason asrc-only hash would not do); - every transition as
source|event|targets, sorted, so reorderingon:keys is not drift.
Deliberately excluded: guard bodies, actions and assigns, prompts, descriptions. Editing a guard changes what a run does next; it does not make the snapshot unreadable. The alternative — any edit to a workflow strands its parked runs — is defensible in the abstract and unusable in practice: it makes jr2 dev's reload a migration event and prompt-tuning a stop-the-world operation. Drift means "I can no longer read what I saved," not "something changed."
Absent means drift. A blob written before the stamp existed cannot be vouched for, and the point is to never interpret one that cannot be. Greenfield, so a stale local .jr2/state.db is deleted, not migrated.
A drifted run is refused, kept, and readable. Not markLost: that nulls the snapshot, and read() returns undefined for a null blob, so jr2 status <id> would answer no run "<id>" — a refusal indistinguishable from a run that never existed, on precisely the run a human most needs to look at. drifted is its own status, the snapshot is untouched, and RunStatus gains reason, which nothing on any HTTP route surfaced before. The refusal names the workflow and both digests.
A throw during restore is a different claim from drift, and is not recorded as one. Drift is durable — the Machine will still have changed on the next boot. A reconcile failure is a kubectl blip, and condemning a run for it would make a transient fault permanent (or, via markLost, throw the snapshot away). Those rows are left live for the next boot to retry, reported through onRestoreError, and counted separately as failed.
One unresumable run is one run. The restore loop is sequential, and a throw used to reject restore(), which rejects startInstance, which crash-loops the pod — taking every later run in the store down with it, unrestored. Each run is now handled independently.
The boot announces what it did not resume. restore() returns { reattached, lost, drifted, failed }, and the entrypoint puts the last three on its announce line. A boot with no current Assembly lists its runs as waiting instead, and the first publish, which restores them, announces one line { assembly, lost?, drifted?, failed? } when any was not resumed (ADR-0069). Resumed runs are routine and stay quiet. Without this, drifted is reachable only by asking after a run id nobody knows to ask about.
Considered options
- Hash the workflow module source, or the whole config including guards and actions. Rejected — see above. Maximal safety at the cost of making every workflow edit a migration.
- Reuse
JR2_CONTENT_HASH, whichjr2 upalready computes and injects. Rejected: it is a digest of the whole staged bundle, so it moves on a lockfile bump, a README edit, or a kit upgrade — constant false drift — and it is absent underjr2 deventirely. - Topology plus named guards (
setup()keys and named functions). Rejected: inline arrows collapse to"inline"in the doc and would stay invisible, so the coverage is uneven in a way no one could predict from reading a workflow. - A
machine_snapshotsschema column. Rejected:init()is a singleCREATE TABLE IF NOT EXISTSwith no migration mechanism, so a new column would silently not appear on an existing PVC. The blob is versioned by being JSON. - Warn and resume anyway. Rejected: there is no safe interpretation to fall back on. xstate resumes a missing state as
undefinedrather than failing, so a warning would be the only signal, on a run already running wrong. - Fold the workflow name into the digest. Rejected: restore resolves the def by name first, inside the run's own Assembly, so a rename never reaches the comparison — it would only make a rename look like a shape change on a run that no longer resolves at all.
Consequences
- Editing a workflow strands nothing. A run holds the Assembly it started under, through restarts too (ADR-0069), so a publish never changes the Machine a live run resolves to. Drift is reached only when a held Assembly cannot be read any more: it does not load under a bumped Kit version, or the volume lost it. The stamp still stands as the last check, because the Assembly hash says which tree was loaded and the stamp says the Machine in it still has the shape it had.
- There is no migration path. A drifted run can be read and cancelled; it cannot be adapted onto a new Machine. That is deliberate for now — a migration story needs a way to express what a state maps to, which nothing here does.
- A run with an Open body keeps its wrapper's stamp. A Chart (ADR-0070) is the run's data, not its Machine's shape: the Chart's text and its hash ride the body's own snapshot, restore admits the text again and compares the hash, and a mismatch or a refusal is drift naming the body slot and the reason, after the stamp check and in the same words.
- A drifted run is not in
jr2 runs, which lists only the live registry. It is reachable through the announce line andjr2 status <id>. If stranded runs become common enough to browse, the listing is where that belongs. - The digest is only as complete as the walk. A state running
enqueueActionscan spawn children no static analysis recovers (machine-doc.ts'sopaqueStates); the opacity is folded into the hash, but what it hides is not. Drift inside such a subtree can go unnoticed. fingerprintOfis memoized per machine object —persistruns on every snapshot microtask, and a dev reload's fresh machine object correctly gets a fresh entry. That is notvocabularyOf's key, which ismachine.configso a.provide()clone keeps the defs it was built with (ADR-0011); a shape has nothing to keep, being derived from the config alone, so a clone recomputes the identical digest.