Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .abcd/development/decisions/0003-work-state-handover-verbs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Work state is carried by explicit handover verbs, not the reconcile loop

- Status: accepted
- Date: 2026-07-18

## Context and problem statement

ferry carries a development *setup* across accounts and machines; it did not
carry *in-flight work*. For an abcd-layout project, continuing work on
another account loses the session handover note and run journal (gitignored
by design), the coding agent's per-project memory (outside any repo, keyed
by absolute path), and the redacted transcript store (account-local). The
project-side tooling deliberately refuses this job. How should ferry carry
operator-local work state between accounts?

## Considered options

1. A new `FileDomain` in the `capture`/`apply` reconcile loop.
2. A new verb family with explicit handover (baton-pass) semantics and a
plain-directory cargo store.

## Decision outcome

Chosen option 2, `ferry work pack|receive|status|prune|restore`, because
work state fails every assumption the reconcile loop makes: it has an owner
(whichever account is actively working), it changes every session (drift is
its normal state, not a signal), and it must never silently merge. The
reconcile engine's `$HOME`-target model also does not cover files inside a
project repo's working tree. Divergence is detected by content hash against
the last-held baseline and surfaced, never resolved automatically; ordering
is by pack sequence number, never timestamps.

The cargo store is a plain directory on shared or portable media — never the
config repo (declarative, long-lived, possibly hosted; personal working
context must not ride along), enforced mechanically: a store inside any git
worktree is refused, a store under a known cloud-sync root needs an explicit
opt-in, and no store file is ever written by two accounts (O_EXCL bundles,
per-account claim files merged on read).

This extends the durable/ephemeral distinction of
[ADR 0002](0002-work-memory-public-private-split.md) across the account
boundary: the ephemeral tier travels only by explicit handover, with the
secret gate fail-closed over every byte.

## Consequences

- Good: no silent merges; every landing is backup-first, snapshotted, and
precisely reversible (`ferry work restore`); the store works on
permissionless media (exFAT, SMB) and across same-Mac accounts.
- Bad: handover is manual by design — there is no continuous sync, and a
forgotten `pack` leaves the other account stale (surfaced by
`work status`, not prevented).
- Neutral: the agent-memory path-key encoding is an observed convention of
one tool, owned by a single registry entry; a harness-side change is a
one-entry fix, and `receive` refuses rather than merges when the target
does not match the recorded baseline.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ called out in a **Breaking** section. See

## [Unreleased]

### Added

- **`ferry work` carries in-flight project work between accounts** as an
explicit baton pass: `work pack` bundles a project's work state — the
session handover note, run journal, per-project agent memory, and redacted
transcript store — into a shared cargo store; `work receive` lands the
latest cargo on another account, backup-first, snapshotted, and behind
divergence guards (never a silent merge); `work status` shows cargo,
claims, and drift; `work prune` applies retention; `work restore` reverts
exactly the last receive, and `ferry restore work` reverts all work-verb
writes. Every packed byte passes the secret gate fail-closed, with
`--exclude` and content-pinned `--acknowledge` as the auditable escape
hatches. The store is configured per machine in the new `[work]` table of
`~/.config/ferry/config.toml`; a store inside a git worktree is refused,
and one under a cloud-sync directory needs an explicit opt-in. See the new
how-to "Hand work over to another account" and explanation "The work
domain".

## [0.9.0] - 2026-07-15

### Added
Expand Down
25 changes: 22 additions & 3 deletions cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/REPPL/ferry/internal/paths"
"github.com/REPPL/ferry/internal/platform"
"github.com/REPPL/ferry/internal/terminal"
"github.com/REPPL/ferry/internal/work"
)

func init() {
Expand Down Expand Up @@ -280,12 +281,20 @@ func scopedRestore(ctx *cmdContext, domains []string, out io.Writer) error {
// resolveScopedPaths would misread the name as a dotfile (~/.agents).
var rest []string
agentsRequested := false
workRequested := false
for _, d := range domains {
if d == "agents" {
switch d {
case "agents":
agentsRequested = true
continue
case "work":
// The "work" domain fans out to every path the work verbs have
// written, enumerated from the per-project work state files —
// like agents, expanded here before resolveScopedPaths would
// misread the name as a dotfile (~/.work).
workRequested = true
default:
rest = append(rest, d)
}
rest = append(rest, d)
}

// resolveScopedPaths refuses ~/.ssh + path-traversal dotfile names (the security
Expand All @@ -302,6 +311,16 @@ func scopedRestore(ctx *cmdContext, domains []string, out io.Writer) error {
absPaths = append(absPaths, p)
}
}
if workRequested {
wpaths, werr := work.AllWrittenPaths()
if werr != nil {
refusals = append(refusals, fmt.Sprintf("restore: cannot enumerate the work domain's written paths (%v)", werr))
}
for _, p := range wpaths {
validDomains = append(validDomains, "work")
absPaths = append(absPaths, p)
}
}
for _, r := range refusals {
fmt.Fprintln(out, r)
}
Expand Down
Loading