Skip to content

feat: work-state ferrying across accounts#24

Merged
REPPL merged 14 commits into
mainfrom
feat/work-state-ferrying
Jul 19, 2026
Merged

feat: work-state ferrying across accounts#24
REPPL merged 14 commits into
mainfrom
feat/work-state-ferrying

Conversation

@REPPL

@REPPL REPPL commented Jul 18, 2026

Copy link
Copy Markdown
Owner

What

Implements the approved plan .abcd/development/plans/2026-07-18-work-state-ferrying.md: ferry work pack|receive|status|prune|restore carries a project's in-flight work state — the session handover note, run journal, per-project agent memory, and redacted transcript store — between accounts as an explicit baton pass through a shared cargo store.

Design highlights (per the plan)

  • Handover verbs, not a reconcile domain — work state has an owner, changes every session, and never merges silently. Companion ADR 0003 records the decision.
  • Store designed for no shared writes: O_CREAT|O_EXCL sequence allocation, per-account claim files merged on read; equal-seq forks surfaced and refused at receive until named or pruned. Ordering is by sequence, never timestamps.
  • Untrusted-input treatment of cargo: bundle bytes verified against the name hash, per-file manifest hashes and sizes enforced, unlisted members refused, member paths containment-validated (no traversal/absolute/drive/.ssh/VCS components, case-fold duplicates refused).
  • Fail-closed secret gate over every packed byte and file name, with two auditable escape hatches: --exclude (recorded in the manifest) and --acknowledge (pinned to file + content hash; re-aborts on change).
  • Backup-first, precisely reversible: every receive write goes through the backup engine inside one journal run, preceded by a per-receive snapshot persisted before the first write — ferry work restore reverts exactly that receive, including a partial one; ferry restore work reverts all work-verb writes.
  • Guards: shallow-clone and linked-worktree refusals (isolated git env, closing the issue-Test git identity (t) can leak into the repo under concurrent git operations #17 GIT_DIR shape), store-inside-a-worktree refusal, cloud-sync-root opt-in, project-under-home v1 rule, divergence-by-content-hash with --force as the explicit override, take-back and superseded semantics.

Verification

  • Unit + -race suites for every package; evals drive the real binary through a two-home pack→receive round trip and the full refusal matrix (7 ACs), with the ~/.ssh tripwire armed.
  • Security review pass: no HIGH/MEDIUM findings; its two LOW observations (path-name gate parity, case-fold manifest duplicates) are fixed in this branch.
  • Review pass found and fixed one real bug: the receive record now persists before writes, so the advertised partial-receive recovery is truthful.
  • Docs: how-to, explanation, reference (+ regenerated CLI pages), CHANGELOG entry, ADR 0003.

Notes for the reviewer

  • internal/work/validateCargoRel is deliberate kin of internal/bundle's private canonicalRel (strict, no cleaning — cargo paths are pack-built). Flagged as a consolidation candidate rather than exporting/reshaping the bundle API in this PR.
  • Pre-existing, untouched: cmd/root.go still says v0.8.0-dev; release ldflags override it at tag time, but a version-bump chore may be due.

REPPL added 14 commits July 18, 2026 15:09
First increment of work-state ferrying (plan 2026-07-18): project
identity as the abcd-compatible root-SHA set, computed with an isolated
git environment (an inherited GIT_DIR must never answer for another
repo — the issue-#17 leak shape), refusing shallow clones, linked
worktrees, and commit-less repos; and the data-driven cargo registry
mapping the abcd layout and Claude Code harness to their work-state
locations and receive policies.

Not wired to any command yet — the work verbs land in later increments
on this branch.

Assisted-by: Claude:claude-fable-5
Second increment of work-state ferrying: the versioned cargo manifest
(validated as untrusted input — path containment, hex identity, strict
decode, newer-version refusal) and the per-account local state file
(pack/receive baselines keyed by content hash, hash-pinned secret-gate
acknowledgements, last-receive record for precise reversal, and the
written-path set that ferry restore work will enumerate).

Assisted-by: Claude:claude-fable-5
Third increment of work-state ferrying: the store layout
(<store>/<root-sha>/<seq>-<hash>.ferrywork with O_CREAT|O_EXCL sequence
allocation and fsynced bundle writes, safe on APFS/SMB/exFAT), the
mechanical store-location guards (inside-a-git-worktree refusal, known
sync-root refusal behind an explicit override), equal-seq fork listing,
and per-account claim files — each written only by its owner, merged on
read, so no file is ever written by two accounts.

Assisted-by: Claude:claude-fable-5
Fourth increment of work-state ferrying: Pack collects the registry
items (missing optional items tolerated and noted; the handover note
required unless --allow-empty), passes every byte through the secret
gate — a high-confidence finding aborts the whole pack, releasable only
by --exclude or an acknowledgement pinned to file + content hash —
builds the cargo zip (sorted members, sanitised modes, manifest stamped
with the sequence the store actually allocated via the new WriteBundle
build callback), appends the pack claim, updates the local baseline,
and records the sidecar handover marker without mutating any cargo
file. Symlinks anywhere in cargo are a refusal, never a silent skip.

Assisted-by: Claude:claude-fable-5
The snapshot machinery was reachable only from inside restore flows.
The work domain needs a snapshot per receive so `ferry work restore`
can revert exactly that receive; Snapshot() exposes the existing
snapshotCurrent unchanged.

Assisted-by: Claude:claude-fable-5
Fifth increment of work-state ferrying: Receive lands the latest cargo
(chosen by sequence, never mtime) behind the full guard set — bundle
bytes verified against name hash and per-file manifest hashes, no
unlisted members, root-SHA set intersection, equal-seq tie refusal
naming both bundles and their claim owners, superseded (taken-back)
refusal, the v1 project-under-home rule, guarded-overwrite divergence
checks against the hash baseline (first-receive-into-populated refused;
identical content is not divergence), union-merge for transcripts under
the store's advisory lock, and memory-target verification. All writes
are backup-first inside one journal run, preceded by a per-receive
snapshot; WorkRestore reverts exactly that receive. Take-back on the
packer's account clears the marker and restores nothing. LocateProject
scans foreign store directories by manifest root-set intersection so a
reordered rev-list cannot orphan cargo.

Also fixes a bug caught by test: a receive plan failing after taking
the transcript lock leaked the lock file (the release defer registered
only after the error check).

Assisted-by: Claude:claude-fable-5
Prune applies the retention policy on demand, refusing keep < 1;
RemoveBundle deletes one bundle by hash — the targeted form the
equal-seq tie message points users at.

Assisted-by: Claude:claude-fable-5
Status assembles the read-only picture the status verb renders: cargo
list, unresolved equal-seq forks, merged claims, handover-marker
freshness ("handed over, not modified since" vs "modified after"),
divergence of guarded items against the local baseline, store size, and
other projects present in the store by manifest repo name.
AllWrittenPaths unions every project's written set — the enumeration
the `ferry restore work` domain form reverts. The machine config gains
the optional [work] table (store path, keep-last-N, sync-root opt-in);
hostname and repo stay the only required fields.

Assisted-by: Claude:claude-fable-5
The work domain is now reachable from the production entry point:
`ferry work pack|receive|status|prune|restore`, self-wired like the
bundle noun. The command layer resolves the project identity (with its
guards), the [work] cargo store from the machine config (missing table
gets setup guidance; --allow-sync-root gives a one-run override), the
per-project state, and a sanitised user@host claim identity. Receive
and work-restore hold the same engine lock apply holds so they never
interleave with a reconcile. Pack applies keep-last-N retention after
storing, and --acknowledge pins named secret findings to their current
content before a single retry. `ferry restore work` fans the domain out
to every work-written path, mirroring the agents expansion.

Verified end-to-end against the real binary: pack → status → receive →
work restore → take-back → superseded refusal across two fake homes
sharing one store. Full unit + eval suites green.

Assisted-by: Claude:claude-fable-5
The Diátaxis set for work-state ferrying: how-to "Hand work over to
another account" (one-time shared-store setup, pack/receive walkthrough,
refusal guidance), explanation "The work domain" (why baton-pass verbs
and not a reconcile domain, why the store is not the config repo, the
no-shared-writes store design, advisory claims, project identity),
reference rows for the five verbs and the [work] machine-config table,
regenerated CLI pages, the CHANGELOG entry, and companion ADR 0003
citing ADR 0002's durable/ephemeral split as precedent.

Assisted-by: Claude:claude-fable-5
Two-sandbox evals driving the real binary through the plan's matrix:
pack→receive round trip across two homes sharing one store (with the
~/.ssh tripwire armed), work-restore reverting a receive, divergence
refusal with --force override, the secret-gate abort with both escape
hatches (--acknowledge pinned to content, --exclude recorded in the
manifest), shallow-clone and linked-worktree refusals, take-back plus
the superseded refusal, and the store-inside-a-worktree guard.

Assisted-by: Claude:claude-fable-5
Pack now gates file NAMES as well as content (parity with bundle
export) — a secret-shaped path component aborts the pack and the
refusal withholds the name rather than echoing it back. The cargo
manifest validator refuses case-fold duplicate file paths within an
item, which case-insensitive APFS would otherwise land last-writer-wins.

Assisted-by: Claude:claude-fable-5
A mid-write receive failure left state.LastReceive nil (or pointing at
the PREVIOUS receive's snapshot), so the advertised recovery — `ferry
work restore` reverts the partial receive — was wrong exactly when it
mattered. The record (snapshot id, bundle, affected paths) is now saved
before the first write; the baseline still updates only on success.
Found in the pre-PR review pass; regression-tested with a partial
landing forced via an unwritable memory target.

Assisted-by: Claude:claude-fable-5
@REPPL
REPPL merged commit 21cf077 into main Jul 19, 2026
11 checks passed
@REPPL
REPPL deleted the feat/work-state-ferrying branch July 19, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant