Context
A person stops work on one machine and continues on another — a laptop in the evening, a
desktop in the morning. Everything that would let the second machine continue without the
person reconstructing the work from memory already exists, on the first machine: the
handover (# Objective, # Current State, # Next Action), the task and its scope, the
decisions that task recorded. All of it is under .ai/local/state/, which is never
committed, and all of it names that machine — an absolute worktree path, a repository id that
is a path, a ledger rank.
Two existing mechanisms move parts of it, and neither covers the case:
- Git moves the source and, since ADR 0014, the closed session's envelope. It does not move a handover, and the envelope's references point at local files the other machine does not have.
- The mesh (ADR 0067) replicates a published handover between runtimes — but only during
a live sync round between two running servers. A laptop that is asleep when the desktop
wakes never meets it, unless a third, always-on runtime relays; and a handover consumed
from the mesh is written as
working_tree: cleanwhatever it was, without the task, the decisions or any lineage.
And the audit found the gaps any transport would inherit: of the four spellings of "which repository" in the codebase only the mesh's — a digest of the root commits — is the same on two machines; nothing redacts or refuses a secret in a handover; nothing records which handover a later one continues, so two machines continuing the same work could not be told from two machines doing different work.
Decision
The portable unit is the handover, not a new object. A published handover
(continuity::record::Record) is the handover's body exactly as the mesh carries it
(HandoverBody) beside what another machine needs to resume from it: the repository identity
(the mesh's root-commit digest), the device (the mesh node key's id and label), the episode,
the source state (branch, commit, clean or dirty, the changed paths and a fingerprint of the
uncommitted change — never its content), the task (title, scope, profile), the decisions that
task recorded, and the record it continues. There is no field for a path of the author's disk,
a process, a port or a socket. The vocabulary gains no noun: it is a handover, published.
It is identified by content and signed by the device. The id is the digest of the
record's canonical JSON; the signature is the device's mesh Ed25519 key over a domain of its
own. The same key is the device's identity; the mesh's trust list
(.ai/repo/mesh/majordomus.yaml, trust.allow) is the list of this repository's devices.
It is refused, not redacted, when it carries what must not travel. Before signing, every
string is checked: a credential of a known shape (redaction.rs), the value of any secret
environment variable of the publishing process, a path of a home directory, and — in the
fields that hold repository paths — anything not relative and inside the repository. The
refusal names the field and never repeats the value. Refusing rather than redacting keeps the
record what its author wrote; the author decides what it says instead.
It travels in refs/majordomus/continuity, written with plumbing. One commit whose tree
is records/<id>.json. A ref of its own rather than files on a branch, for reasons this
repository has already paid for:
- a record is written with
hash-object/mktree/commit-tree/update-ref, so no branch, index or working tree moves, and a dirty tree is handed over without a commit to the work; - no pull request carries it, no merge conflicts on it, and no derived projection of the tracked tree is invalidated by it (a tracked record would re-stale the site data);
- every worktree of a clone sees it, because refs live in the common directory;
- the pre-push contract (
finish --check) asks whether a task's work may be published; a push that moves onlyrefs/majordomus/*carries no work, so the hook admits it rather than refusing a handover for exactly the task it hands over.
Merging two stores is the union of their files: records are immutable and content-addressed,
so the same name is the same bytes, and a name holding different bytes is reported and the
local copy kept. A plain git push or pull leaves the ref alone; continuity sync moves
it, explicitly, through an ordinary git remote. publish never touches the network, so a
publication offline succeeds and stays pending until a sync reaches the remote.
Order is lineage, never the clock. A record names its parent — the record this checkout
last published or resumed on the same branch — and its line (the first record's id). Two
records are equal, newer, older, diverged (same line, neither descends from the
other: the same work continued twice) or independent (different lines: separate work).
published_at is shown and used for an age; it decides nothing. Separate lines on two
machines are ordinary and reported as such; a diverged line is a conflict a person resolves
by naming a record.
Every record is admitted before anything reads it — on every read, from either store:
bounds, schema (a later version is too_new, kept in the store and reported as an upgrade),
id against content and file name, signature against the device key, repository identity
(a fork shares a name and not a root history, and is refused), portability. What admission
refuses for a reason no later executable would reverse — forged, foreign, leaking — is left
out of the tree a sync merges.
Resume acts only on a plan. continuity plan writes nothing and decides: is the signer
trusted (an untrusted signer is refused; with no trust list declared it is a warning), does
the record diverge from the one this checkout stands on, does the local source hold its
commit and branch (head_missing, local_behind, branch_differs require a source update;
diverged source is a conflict; nothing here runs git), was it written in a dirty tree
(source_incomplete: the context is restored and the source is said not to be), may the
other device still be working (advisory: nothing locks). continuity resume proceeds only on
ready or ready_with_warnings: it writes the handover into this checkout's handovers through
the writer the mesh uses — where handover --resolve and the session briefing find it —
appends the task's decisions to the decision log once each, and stands on the record. The
commands a plan lists are recommendations; nothing a record says is executed.
The entry banner reads a cache. sync, publish and resume leave the resumable
handovers in .ai/local/state/continuity.json; the preflight's session.continuity check
reads that file and nothing else, so entering a directory costs no git call and no network.
Alternatives rejected
Git alone — the handover as a tracked file on the branch. Mid-task the pre-push contract refuses the push; a dirty tree needs a commit of the record that the work is not ready for; every record appears in the pull request and re-stales the derived site data. The tracked envelope of ADR 0014 stays; it records an episode, not a continuation.
The mesh alone. It requires both machines, or an always-on relay, online together, and it
carries no lineage, no source state and no task. The mesh remains the live transport between
running servers; this ref is the asynchronous one. Both carry the same HandoverBody and
write a received handover through the same writer.
A shared database or a hosted state service. A server to run for every repository, and a second account of state git already holds.
A CRDT or consensus. Records are immutable, so a grow-only set is already conflict-free at the storage level; the only conflict that matters — the same work continued twice — is a semantic one a person resolves, and no merge function can resolve it for them.
Choosing the newest record by timestamp. Two clocks on two machines; and "newest" would silently discard the other machine's continuation.
Redacting secrets instead of refusing. A redacted handover is a different handover from the one its author wrote, and the redaction is invisible to the person resuming it.
Consequences
- A person runs
majordomus handover,majordomus-cli continuity publishandcontinuity syncon one machine, andcontinuity sync,planandresumeon the other. The provider's end hook already writes a handover; publishing it automatically is left to a later decision rather than added to every session end. - Each device's key must be on the repository's trust list for its handovers to be resumed
where a list is declared — the same list the mesh uses.
continuity device --labelnames a device; the key, and so the identity, never changes. - The continuity ref is pushed to whatever remote is synced. In a public repository its
records are public, like the tracked session envelopes: handover bodies are prose about the
work, refused when they carry a secret or a machine path. Sync with a private remote
(
--remote) where that is not acceptable. - Retention is git's: records are small and immutable, and nothing prunes them. A pruning policy, if one is ever needed, must keep every record a line still descends from.
- Uncommitted source never travels. A handover from a dirty tree says which paths and how many; moving the work itself is git's job, and the plan says so.
Verification
test/cases/821_a_session_moves_between_machines.sh is the two-machine run end to end;
822 (dirty tree and source compatibility), 823 (divergence), 824 (independent sessions),
825 (offline), 826 (secrets and machine paths), 827 (tampering, schema, fork, untrusted
signer). The record, lineage, store and local modules carry unit tests of each check.
Amendment, 2026-10-15: the pre-push contract judges the branches being pushed (I1990)
The hook already reads the refs git passes on standard input, but only to admit a push that
moves nothing but refs/majordomus/*. Any other push asked finish --check of the checkout's
own task, so pushing branch X from a checkout whose task is on branch Y failed with
different_context. OSCILLA pushed feature branches from its main checkout to get round it.
Each pushed refs/heads/<b> is judged by the task of the checkout that has <b> checked
out, which is its canonical worktree; the current checkout's task is asked only of its own
branch. A pushed branch that no checkout holds, or whose checkout has no active task, is
judged as a push without a task is today: admitted, with one line naming it. refs/majordomus/*
stays exempt, and a deleted ref (all-zero local sha) is skipped. Binding a task to its branch
remains as before; only which refs the hook looks at is decided here.
Rejected: judging the checkout's own task for every push (it forces pushing from the wrong checkout), and judging nothing on pushes of other branches (it drops the scope contract of a branch whose own worktree has a task).
The work is two slices, one pull request each:
finish --checktakes the refs.lib/finish.shaccepts the pushed refs, resolves the worktree holding each branch (git worktree list --porcelain), and asks that worktree's task;.githooks/pre-pushpasses the refs it read. Caseprepush_refs_case: from checkout Y (task on Y) push branch X whose worktree's task is out of scope, and the push is refused naming X; with X in scope it is admitted; Y's task is not asked.- Adopters get the same hook. The init-installed hook in
share/skeletonand the hook lines indocs/CLI.mdcarry the same behaviour, and the case runs against the installed hook.