CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
## v0.6.3 - 2026-10-27
v0.6.3. v0.6.2 was not published separately; its changes (below) ship in this release together with everything merged since.
Also in this release. A native MCP server (beta, ADR 0028): `ferry mcp serve` lets an AI app join a project as an agent over stdio (join, catch up, orders, talk, primary, approvals), with a seat check, signed-only updates, redaction of outbound text, and approvals refused from agents (docs/MCP.md). Opt-in expiry for hand-ins and the outbox: a project's master can set `ferry channel ttl` and `ferry channel expire --dry-run` shows what would go; nothing expires by default (docs/TTL.md). A Hive rescue channel (opt-in, feature-gated crate `ferryman-hive-rescue`, `ferry rescue hive status|post|read|mock`): a cut-off master posts one sealed, master-signed custom_json to a fleet Hive account and cut-off machines verify it against the roster and add the rescue relay to the live iroh lane; signing uses k256 0.14 (crypto-bigint 0.7.10, RUSTSEC-2026-0387). Dependencies: zstd 0.14.3 (RUSTSEC-2026-0391, -0398 and zstd-safe advisories). Fixes: the setup dry-run snapshot test no longer flakes (#101), and the counter allocation lock waits up to 60 seconds (`FERRY_COUNTER_LOCK_SECS`) so a loaded machine does not fail it.
A quiet worker no longer burns CPU re-reading what has not changed. The agent roster was read from disk on every ask (dozens per task, about 60 a second on a real fleet), each time opening every agent file, both pins of each agent and the whole fleet roster, and every signature was checked again on every pass. Rosters are now remembered while none of their files changed (name, size and modified time, the same rule the task cache uses, and never for a file written in the last two seconds), and a signature already found good is not checked again for the same key, signature and bytes. Pins still override a forged roster entry. On a 50-project root this was about half a core.
Timed access for invited people (ADR 0029). Access an invitation gives is now either timed or open, and the master chooses on every invitation: `ferry team invite create` requires `--access <DAYS|forever>` (a machine added with `--as-identity` takes none; it lapses with its owner), and the dashboard's invitation form has a required "Access lasts" choice next to "Code valid for", which is the code's own lifetime as before (`--expires-days`, default 7). Timed access ends N days after the master's grant is signed, for the person and their agent alike, and counts as revoked everywhere a revocation does: the gate, workers, the iroh lane, delegations, the doctor. A few days before it ends (three, or a third of the term for terms under nine days) the master is asked Renew or Let it end, on Telegram and on the Team page; the answer is their signed answer, renewing one more term, and no answer lets it end. The agent loop unshares the Syncthing folder from the devices of people whose time ran out. `MasterGrant` gains a signed `expires_at` and `Invite` a signed `access_days`, both appended to the signed bytes only when present, so grants and invitations written before this verify unchanged. What had already synced stays on their disk, and secrets sealed to them should be rotated. New API: `POST /api/questions/{id}/answer`; `/api/team` rows gain `access_ends`, `access_lapsed` and `pending_access`; `POST /api/team/invite` requires `access_days` or `access_forever`.
New projects reach every machine of the fleet, connectors are seated in them, and a running worker serves them without a restart (ADR 0030). A project made on one machine used to stay there: nothing filed it on another, the iroh lane only carries projects both sides already serve, and first contact needs the peer's worker on the roster on both sides. Now a machine's directory entry in the fleet folder also says where the project's clone came from (`git_remote`, credentials never published) and who masters it (`master`), and its machine record names its all-projects worker (a name and a public key) and what it excludes. Every worker pass puts the other admitted machines' workers on the rosters of the projects it serves, and files a project another admitted machine advertised (live, the fleet master's, not excluded here, not swarm-off) as a channel with both workers on its roster, then clones and attaches it, at most two clones a pass and each git command on a clock (`repos::Options::git_timeout`; a failing remote is retried after five minutes). All the new fields are signed and optional, so old records still verify and old binaries ignore them. `[seat] agents = ["claude-ai-josh"]` in the root's `ferry.toml` lists identities this machine serves to apps; the worker seats the key the machine already holds (another attachment or the machine key store, never a new one, never into a checkout git would commit, refused when two different keys exist) in every project it serves and in each fresh clone, and `ferry root seat <name>` / `ferry fleet setup --seat <name>` add a name and seat it everywhere at once. The grant still comes from the master (`team approve --all`). The running worker also says why a project that appeared after it started is not served yet (the first reason used to be recorded in silence), and adds the fleet-home route to the lane when the folder appears after the lane started.
Lanes: several agents, one repository. `ferry lanes new <project> <lane> --owns <glob>` gives each agent or task its own git worktree (`<repo>/.lanes/<lane>`, branch `lane/<lane>`, excluded from the main checkout's status) and a signed record of the paths it owns in the project's channel; a per-worktree pre-commit hook (`extensions.worktreeConfig` and `git config --worktree core.hooksPath`) refuses staged paths another lane owns, allows and reports unowned paths and shared files (DECISIONS.md, README.md, CHANGELOG.md), and takes `FERRY_LANE_OVERRIDE=why` as a logged override. `ferry lanes land <lane>` rebases the lane in a scratch worktree and fast-forwards its base, one landing at a time (an OS lock plus a signed claim in the channel); on a conflict it aborts, leaves the lane and the base untouched, lists the files and posts a notice to the lane's owner; `--push` uses the sealed `GH_TOKEN` from memory, handed to the one git process as `GIT_CONFIG_*` environment and never written. `ferry counter next <name>` hands out sequential ids (decision numbers, asset ids) through signed claim files: strictly unique on one machine, final across machines after a settle window, with a master-signed allocator or disjoint ranges where that is not enough. `ferry lanes list|enter|leave|retire`, `ferry counter list|check|policy`, lanes in `ferry root show`, and `ferry doctor` warns about two agents sharing a working tree and about stale or far-behind lanes. `ferry lane iroh` is unchanged. See docs/LANES.md and docs/adr/0031-lanes.md.
## v0.6.2 - 2026-10-23
v0.6.2 was not published separately; its changes ship in v0.6.3.
Fleet hardening (v0.6.2). A worker that updates itself now comes back by itself, the iroh lane says when it is unhealthy, it works with or without a relay of our own, and `ferry lane iroh status` says what each peer is doing.
Restart after a self-update, whatever supervises the worker. The worker used to stop with exit status 0 after installing a release, which `Restart=on-failure` and a Windows task's restart-on-failure read as "done", and with no supervisor it stayed stopped. It now asks to be restarted and `main` carries it out once its locks and lane are gone: on Unix it `exec`s the new binary in place with the same arguments (the same process id; a binary replaced while running is found at its plain path, not `<path> (deleted)`); on Windows under the scheduled task it exits 75 (`EX_TEMPFAIL`, which the wrapper passes on and the task restarts; the wrapper now sets `FERRYMAN_SUPERVISED`, and a wrapper written before 0.6.2, which does not, is recognised as the task by the wrapper script among the worker's parent processes and is marked in place when the worker starts and before it restarts, the old file kept as `ferryman-worker.ps1.before-supervised`, so the first auto-update on such a machine does not leave the worker stopped); on Windows with no supervisor it starts a detached copy that waits until a time five seconds ahead (`FERRYMAN_REEXEC_NOT_BEFORE`, a time that delays nothing once passed, so nothing the copy starts later waits), then exits 0. A fleet `restart-worker` order goes the same way. A loop guard in `reexec.json` (machine state) allows three restarts an hour, and none at all when there is no machine state directory to keep the count in; a fourth is refused and the worker carries on with the version it has. docs/FLEET_SETUP.md has the recommended supervisor settings (`Restart=always` for systemd; the task's restart-on-failure, 1 minute, 999 times, no time limit).
Lane health alerts. The worker works out each pass whether a peer has failed every exchange for two minutes, has had no good sync for `alert_after_minutes` (default 15; counted from the lane's start for a peer never reached), or whether a relay is meant to be in use and none has connected for two minutes. A peer both failing and quiet is one alert. Each alert is raised once and cleared once (state in `lane-alerts.json`, machine-local): the worker logs it, `ferry doctor` has a `lane` line, `ferry lane iroh status` an `ALERT` line, the dashboard's `/api/fleet` a `lane_alerts` object, and the fleet gets `lane-health/<machine>.json` in the fleet folder, signed by the machine's own identity about its own machine only, listing the alerts standing now (an empty list is the all-clear). It is a folder of its own and not a notice kind: machines on 0.6.1 and 0.5.30 reject a notice of an unknown kind and show it as ignored, and they never read that folder. A machine the master has not admitted cannot write it and says so. There is no Telegram push: the Telegram bridge only relays orders and results. Alerts stand across a restart of the worker and are cleared only on positive evidence (a successful sync since the worker started, a connected relay, a good configuration), so a restart neither clears nor re-raises them; peers that come and go are named in `intermittent_peers` in `[iroh]` and never raised on, every other peer is expected to be on all the time. A faulty fleet relay list is an alert too (`relay-config`).
Relays: ours, iroh's public ones, or none. `[iroh]` takes `relay_url` and an ordered `relays` list (ours, primary first, pinned by `relay_cert_sha256`), `fallback_public_relays` (off unless set: iroh's public relays are added only while none of ours has connected for two minutes, and are checked against the public CA roots while ours still need the pin), `direct_only`, and `alert_after_minutes`. With no relay named the lane has no relay and says so (it fails closed, as before); iroh's public relays are used only with an explicit `public_relays = true`. The relay token stays optional everywhere: it is put on the relays this machine's own `ferry.toml` names when the file exists, never on a public one and never on one that only the fleet's list names. The worker publishes the relay it is homed on in its TRANSPORTS record and republishes when it changes. The public-relay paths are covered by tests of configuration, the relay map and the certificate check, not exercised against n0's servers.
A fleet relay list. The master signs an ordered list of relays into the fleet folder (`fleet/relays.json`, kind `relays`; `ferry fleet relays [list|set|add|remove]`, entries `https://host[:port]/` with `=<sha256>` for a certificate no public CA signed). Every machine knows the relays named in its own `ferry.toml` first, then the list in the master's order, as candidates: iroh picks the home relay among them by latency and moves when that one stops answering, so the order is not a promise of which relay carries a connection. The worker republishes the relay it is homed on. Only the master's list counts (a file signed by anyone else, or edited, is ignored and `ferry doctor` says so); an entry that is not an `https://` address or has a bad pin is dropped. A pin in the list is the master's word for that relay's certificate and applies to that host alone, so a second relay with its own certificate needs no change on any machine but the master's; an entry with no pin must present a certificate a public CA signed. The list is read when the worker starts: a changed list is noticed and takes effect on restart (`ferry fleet order restart-worker`), and the worker says so. The list fails closed: one that is missing, unsigned, edited, empty or older (by `written_at`) than the last one accepted is not used, the last good list (`fleet-relays-seen.json`, machine state) stays in use or, with none, the lane has only the relays `ferry.toml` names or none, and a `relay-config` alert is raised; it never falls back to public relays, and an empty list cannot be published. With the fleet list and nothing in `ferry.toml`, the plan is the list, not iroh's public relays; `direct_only` still means none. Public relays remain an opt-in fallback (`fallback_public_relays`) and are off for a setup that pins a relay. Running a relay on a fleet machine is not built; see docs/adr/0027.
`ferry lane iroh status` now prints, per peer: last success (and how long ago), last error (and since when it has been failing), whether the exchange went direct or through a relay and which, the version the peer runs (told in the exchange; older builds say none), and files each way. `--json` has the same fields, the alerts, the relays allowed and the version.
## v0.6.1 - 2026-10-21
The fleet has a home of its own, and a project can say where it is. `ferry fleet init` (the master, once) makes `<root>/fleet/`, a synced channel with the Syncthing folder id `fleet-ferryman` that the iroh lane carries too, separate from every project, for what is true of the whole fleet: `directory/<project>/<machine>.json` (where a machine keeps a project: code, channel, hand-ins, data, env, work, placement, layout, live or archived), `machines/<machine>.json` (version, layout, root, OS, engines, projects served, last seen), `notices/` (layout-change, deprecation, update-available, migration, general, each with a severity and an optional expiry), `orders/` (fleet orders in the ordinary order format: update, migrate, restart-worker, to one machine or all), `releases/`, and a `librarian/` folder that only holds a README. A file counts only when it is signed, verifies against the key the folder's roster pins, and its signer has standing: the master for anything, a machine the master admitted (`ferry fleet admit`) for its own entries and its own layout-change or migration notices, never a Worker-level agent. An unsigned, forged, edited or unadmitted file is ignored by every reader, and `ferry doctor --layout` (the new `fleet` check) and `ferry doctor` (a `fleet_home` line) say what was ignored. A path in the directory is data: one with `..`, a control character, a UNC or device prefix, a drive-relative form, an alternate data stream or a reserved device name is refused, and nothing ever opens or runs one. No secret goes in the folder; a project's `.env` is recorded as a path only. The directory is one small file per project and machine, plus a master-signed `<machine>.override.json` that wins, so no file has two writers. `ferry where <project> [--machine M] [--json] [--kind code|channel|handins|data|env]` prints that place and nothing else, from any folder (`--all` lists every project on every machine); this machine's own manifest answers for this machine, so the answer is right the moment a migrate finishes, and the directory answers for every other machine. `ferry where --session-hint <project>` prints a short block for a CLAUDE.md or AGENTS.md, and `ferry fleet write-hints` shows, and with `--apply` writes, that block between `<!-- ferry:where begin -->` and `<!-- ferry:where end -->` in each clone's CLAUDE.md (made if missing) and AGENTS.md (where there is one): it touches only its own markers, keeps the file's line endings, refuses damaged markers, and never stages, commits or pushes. Every worker publishes its machine record and directory entries when it starts and on each pass, and `ferry enable`, `ferry root adopt`, `ferry root data` and `ferry root migrate --apply` publish at once; `root migrate --apply` updates the directory and then posts a signed layout-change notice listing every old and new path. A worker says each unread high-severity notice once, says when a live `update-available` notice names a newer release and whether this machine updates itself (`FERRYMAN_NO_AUTO_UPDATE`), and acts only on fleet orders the master signed: `restart-worker` stops it between passes for its supervisor, `update` installs the newest release unless auto-update is off here, and `migrate` is reported and left to a person, since a migrate refuses to run while a worker does. `ferry fleet init|admit|publish|notices|notice|order|orders|machines|write-hints` is the command set, and the dashboard's Fleet page shows the notices, the machines (version, layout, last seen) and a directory you narrow with the filter box. Release requests, approvals and denials are written to `fleet/releases/` when the root has the folder, and read from there and from the `ferryman` project channel's `release/`; the fleet folder's copy wins a tie, the old place is kept current too while older machines are about, and nothing is deleted from it. The pinned keys in `comms/agents-pinned/` are mirrored into `fleet/local/agents-pinned/` (the old copy stays the authority while it holds a pin, and is never removed); `comms/runtime/` is not moved, because its worker locks only exclude a second worker if every binary looks in the same place. The folder is additive, and a binary that does not know it never reads it. Each project also gets a `data/` place, `projects/<id>/data` by default (the same place in layout 1 and 2), for a database, a model or a dataset. It is machine-local and never synced: the Syncthing guard refuses it and any folder that holds it or lies inside it, wherever it is recorded; the iroh lane keeps it out of a share it sits in and exchanges nothing if it is the share; `ferry enable --data PATH` and `ferry root data <project> [PATH|--reset]` record another place and refuse one inside or around a synced folder; and `ferry doctor --layout` has a `data` check and `--fix` makes the folder. See docs/adr/0026-a-home-for-the-fleet.md, docs/LAYOUT.md, docs/FLEET_SETUP.md and docs/IROH_LANE.md.
## v0.6.0 - 2026-10-18
`ferry release status` no longer refuses a release because a request names a commit by its full SHA and the check spelled the current commit short (or the other way round). `release prepare --commit <full sha>` wrote the full SHA into the request, `release status` compared it as text with `git rev-parse --short HEAD`, and the two never matched, so the approval looked stale. Both sides are now resolved to full SHAs with `git rev-parse` before they are compared, in the approval check and in the denial check, and a commit that cannot be resolved is never a match. Only a commit's own name is resolved (4 to 40 lowercase hex digits): `main`, `HEAD`, a tag or any other ref moves, so it is never a match, not even against itself. The signed request, approval and denial formats are unchanged.
An idle worker no longer re-reads and re-verifies every file on every poll. A pass used to open, parse and signature-check every file of every task, and to read the roster, the master and the grants again for each task. Now the roster, grants and master are looked at once per pass, and a task folder whose files have the same names, sizes and modified times as the last read, under the same roster, Worker set and control files, is served from this machine's memory (`readcache`; machine state, never the channel, gone on restart). A change to the roster, a key, a grant, a revocation, a delegation, the head or the master changes the trust key, so every verdict is judged again; a file written in the last two seconds is never remembered; a task with an offer or a delegated review is read afresh each time. On a synthetic channel (100 tasks, 400 signed files) one poll fell from 3,675 ms to 94 ms, and one poll over 1,500 tasks (6,000 files) now takes about 820 ms. The iroh lane's BLAKE3 manifest already keyed hashes by path, size and modified time; it now also forgets files that have gone and does not remember a file written in the last two seconds. `cargo test -p ferryman-channel --release --test read_bench -- --ignored --nocapture` measures it.
An iroh-only channel no longer talks about Syncthing. `ferry channel send` said "Syncthing could not confirm it syncs this channel, so it may not leave" and waited about two seconds on a Syncthing that does not run (the wait is Windows retrying a refused connection to its API), and `ferry channel status` named a Syncthing folder, even on a channel set up with `ferry enable --no-syncthing`. When this machine's iroh lane carries the channel (the lane is on in `ferry.toml`, the machine has an iroh key, and the channel holds a roster-honoured transports record naming it), `send` asks Syncthing nothing, does not wait, and says the iroh lane is carrying it, how many peers have synced it, or that the worker's lane has not run lately and the message goes out when it does; `channel status` gains a `transport` line and says Syncthing is not used when it has no folder for the channel; `channel order` prints the same line. A channel Syncthing carries behaves as before.
One project's memory no longer reaches another project's prompts. A fleet worker is one agent, with one key, on every project's roster, and the signature on its profile (`memory-bank/agents/<agent>.md`, the "what you have worked on in this project before" block) named the agent and the bytes but not the project, so a copy of one project's profile in another's memory bank verified there and went into that project's prompts (quantly's into iroh-test's). New profile signatures (`ferryman-profile/v2`) cover the project id, so a profile signed for one project does not verify in another, for the agent's own profile and for the peer profiles and routing hint built from the bank; a profile signed for another project is set aside (`*.other-project-<time>`, never deleted) the first time the worker writes in its own project, instead of being added to and re-signed as its own; and a `memory-bank` that is a link out of the project's channel is neither read nor written. Profiles written before this (v1, no project in the signature) say nothing about which project they belong to, so inside a project they are no longer trusted: they read as unsigned until the project's master runs `ferry memory adopt-v1 --project <id>` (on a machine that holds each agent's key; it re-signs a v1 profile whose signature holds as v2), and a write never adopts one - an unadopted v1 profile is set aside (`*.unadopted-v1-<time>`, never deleted) like another project's. A `memory-bank` that is a symlink or, on Windows, a junction, or whose channel cannot be resolved, is not used. What carried the files across on the affected machines could not be determined from the code or confirmed on the live channels, which were not touched; check iroh-test's `memory-bank/agents/` for lines that belong to quantly. The worker-wide `preamble_file` is deliberately not project-scoped (`docs/FLEET_SETUP.md`: one preamble covers every project) and is a second way project text can reach every prompt if the file holds it.
Orders now carry a signed `priority` (high, normal or low). The priority is signed only when it is set explicitly (`ferry channel order --priority <high|normal|low>`, or the dashboard's New order form); an order without one carries exactly the bytes it always had, so workers on an older binary still accept it. A worker works an unsigned order at its issuer's default, read from the roster at claim time: high for the master or a roster entry with role operator (and for an order with no signer), normal for an agent. A worker takes work it already holds first, never preempted, then the highest priority, then the oldest. The field is bound into the signature only when present, so existing orders keep their bytes, read as the issuer's default and still verify; changing or stripping the priority of a signed order makes it invalid. The dashboard shows the priority in the task list and the detail drawer. Priority orders claims within one project; the fleet still walks projects in focus order.
`ferry channel compact` rolls old, acknowledged messages into the channel's `archive/` folder: one file per month per thread (the pair of names, whichever way a message went), holding each message's and acknowledgement's original file text byte for byte with its SHA-256, so signatures still verify. The age is the master's, `ferry channel files policy --archive-after-days` (30 unless set), signed into the engine policy. Nothing is deleted without a signed record first: a pass writes the archive lines, then a record under `archive/records/` signed by the compactor, and originals are removed only by a later pass, after a grace (24 hours by default), that finds each one intact in the archive; a file that changed since is left alone and a damaged archive line is never trusted or rewritten. Only the channel's head agent compacts, or its master when it has none; anyone else is refused. Passes on one machine take a lock and run one at a time; two machines running as the same agent can overwrite each other's archive file, which cannot lose a message (an original goes only when its line is found intact in the archive at that moment) and the next pass writes the line again. The grace is at least one hour unless `--force-no-grace` is given. An archive file stops at 32 MiB and continues in `<thread>.part2.jsonl`, and the acknowledged check reads an index of archived ids, cached by file size and modification time, not the archive itself. `ferry channel compact` is Syncthing-only for now: the iroh lane never deletes, so it would resend what was compacted, and it refuses a channel the lane carries. `read_message`, `is_acknowledged`, the idempotency-key lookup, `ferry channel log`, `ferry channel inbox --all` and the server's message list read the archive when the live file is gone. A worker's hand-in folder is not compacted.
Two machines that already share a project over iroh now find each other in a new one without a hand-copied file. A peer trusted in one served channel is offered, for each other channel it is not yet trusted in, the `transports/<agent>.json` records of that channel and nothing else, both ways: no tasks, messages, roster entries or hand-ins. A received record is kept only if the channel would honour it as it stands (named for its agent, signed for this project with the roster's pinned key, not revoked, not from the future, under 8 KiB); then ordinary trust applies from the next round. A channel is offered only to a peer whose agent is already on its roster (`agents/<agent>.json`), and a record never replaces a newer one already on disk. The agent's own `agents/<agent>.json` must already be in the new channel for the record to be honoured; docs/IROH_LANE.md says so under "First contact in a new channel".
A running `ferry agent run --all-projects` worker now notices an approval without a restart. Each pass it asks again which projects are open to it (`ferry team approve <agent> --all` on the master's machine) and starts serving the new ones live: the worker lock, this version stamped on the channel, the hand-in folders, and a place in the iroh lane (published and served from the next connection). Each pass it also re-reads every served project's roster, so a person approved since the worker started is not a stranger to its signature checks. A project that stays closed is named once and again only if the reason changes, and so is a worker lock another process holds. A project that is archived, or that its master takes the worker out of, leaves the iroh lane at once and joins it again if it comes back.
Every project now has one folder, and what an agent may do in it is a signed grant (layout 2, ADR 0024). A new root has `projects/<id>/{repo,comms,handins,work,tmp,.env}`, `secrets/` and `logs/`, so you can hand an LLM `projects/<id>/` and it sees that project and nothing else; a layout-1 root (`comms/ repos/ work/`) keeps working unchanged. The layout is a default, never a rule: each manifest entry records its own paths and a `placement` of `managed` or `custom`, `ferry enable --channel P --repo P --handins P --env P` places a project where you say, and `ferry root migrate` and `doctor --layout` respect it (a custom project is left in place and is not a failure). Hand-ins get a synced folder of their own, `projects/<id>/handins` (Syncthing folder `<id>-handins`, iroh share `<id>+handins`), and everything a worker-level agent writes (hand-ins, order claims, status and replies, its iroh transports record) goes to `handins/<agent>/`; the worker loop, head, dashboard and doctor read those signed files as if they were in the channel for that agent, and only the head writes `accepted.json` and `rejected.json`. Access levels Worker, Member and Orchestrator are grants the project master signs into the channel (`ferry access show`, `ferry access set <project> <agent> worker|member|orchestrator`); an agent with no grant is a Member until the project has a Worker, after which a roster name the master has not admitted counts as a Worker too (the roster is unsigned, so a Worker could otherwise register a fresh name and be a Member); setting the first Worker signs everyone already on the roster in as a Member. The master and the named head are Orchestrators, and a forged, edited or replayed grant is ignored. A Worker reads the channel and never writes it, and that is enforced by the machine that receives a file: every iroh lane and every channel reader refuses a channel file authored by a Worker-level agent, signed or not, and accepts its writes only from `handins/<agent>/`; on the Worker's own machine its lane publishes none of the channel and its Syncthing folder is set receive-only. A Worker or Member loads only its project's `.env`, and only an Orchestrator can read `secrets/`, judged by resolved path so a link or a custom placement does not change it. The Syncthing guard now allows only `projects/<id>/comms` and `projects/<id>/handins` (or recorded paths) and refuses the root, `projects/<id>` itself, `repo/`, `work/`, `tmp/`, `secrets/`, `service/` and any folder with an `.env`. `ferry root migrate` moves a layout-1 root a project at a time: a dry run that writes nothing unless you pass `--apply`; it refuses a project with a running worker, lock or live worktree and says what blocks it, moves the channel, hand-ins and Ferryman's own clone, repoints `bridge.toml` and Ferryman's Syncthing folder paths (ids kept), prunes worktrees, leaves stale worktrees and custom projects alone and lists them, deletes nothing (`_to_delete/`), logs to `logs/migrate-<UTC>.md`, and sets the manifest to version 2 only when every managed project moved. `ferry doctor --layout` gains a per-project folder check and a keys-and-levels check (no `secrets/` or `.env` in a synced folder, no write path into the channel for a Worker on this machine). Levels refuse nothing in a layout-1 project, where a Worker has nowhere else to write; `access show` says so. The head's `accepted.json` and `rejected.json` are signed and an unsigned or forged one is ignored. This is access control, not a sandbox: agent processes on a machine share an operating-system user, so a process that goes around Ferryman can read `secrets/`. `root migrate` holds the Syncthing folder paused while the hand-ins leave the channel and `handins` is added to its `.stignore`, so a peer that has not migrated does not see them deleted, resumes it on every way out, records the channel's new place straight after the move so a rerun can finish an interrupted one, stops a project whose copy could not be set aside, judges each project on its own while the root is still layout 1, and counts a worktree as live only when a running order uses it (`--prune-stale-worktrees` moves the rest to `_to_delete`). A hand-in verdict that is there but cannot be verified (an old unsigned one, a forged one) reads `unverified` and `work accept`/`reject` refuse it without `--force`; `ferry work adopt-verdicts [--yes]` signs the old ones once, and a verdict stays good after the head role is passed on. A name that registers after the first Worker waits as a Worker until `ferry access admit` (nothing admits it automatically). `root migrate` records an unfinished run in `projects/<id>/.migrating` so a rerun finishes it, gives a folder back paused if it was paused, resumes at the old path only if the channel is still there, warns when peers still report layout 1, and prunes only clean, pushed worktrees under `work/` (also for projects that moved earlier); `ferry doctor` says which peers have not migrated and who awaits admission. Not built yet: a Worker with no git credential at all (orders carrying a git bundle), signed claims and heartbeats, an Orchestrator other than the master signing an admission, and a master-only level control on the dashboard. See docs/adr/0024-one-folder-per-project.md, docs/LAYOUT.md, docs/IROH_LANE.md and docs/FLEET_SETUP.md.
## v0.5.30 - 2026-10-18
Channels can sync between machines over iroh (QUIC), through our own relay, beside Syncthing. With `[iroh] enabled = true`, a relay URL and the relay certificate's SHA-256 pin in the ferry root's `ferry.toml`, and the relay's access token in `iroh-relay.token` in the machine state directory, `ferry agent run --all-projects` serves and syncs every channel it serves with every trusted peer every 30 seconds: a manifest (path, size, BLAKE3, mtime) per shared project, then only what is missing or older, written atomically. `ferry lane iroh init` makes the machine's iroh key once (beside the operator seed, never in a channel) and publishes a signed `transports/<agent>.json` into each served channel; a peer is let in only if its endpoint id is in such a record signed by an agent on that channel's roster who is not revoked, and is offered only the channels it is trusted in. The channel's `.stignore` exclusions apply both ways, conflict copies, temporary files and files over 64 MiB never move, nothing is deleted, nothing is written outside the channel (no `..`, absolute paths or symlinks), and the newer mtime wins. The relay's self-signed certificate is trusted by its pinned hash and no other; no n0 relay or DNS is used. `ferry lane iroh status` shows the endpoint id, whether the relay accepts us, and each peer's last sync and file counts. Off by default. See docs/IROH_LANE.md.
`ferry fleet setup --service` on Linux writes a worker unit that loads. It quoted the `WorkingDirectory=` path, which systemd reads literally, so the unit failed with "path is not absolute" and the worker never started.
## v0.5.29 - 2026-10-15
An all-projects worker on Linux starts again. v0.5.28 counted the worker's own threads as rival workers, because the Linux process table lists every thread under its own id, so `ferry agent run --all-projects` refused to start on grouchly and systemd kept restarting it. Threads are now skipped, and a test pins that a process's own threads are not read as processes.
## v0.5.28 - 2026-10-15
An engine is called up only when it has answered, and an engine that hangs hands the order on. On grouchly a model behind a cline shim took requests and sat on them for 300 s; `ferry engines` called it `up, 0.3 s` because the probe only checked that the address answered, so the router kept sending it work, four orders timed out and nothing was flagged. Now every probe asks for a real completion: an http engine gets a one-token chat request with a 20 s deadline (the default, not only for NVIDIA), and a CLI engine that wraps an API gets a tiny prompt through the same command (45 s, at most once every 15 minutes, and again right after a failed run). An engine that cannot be tried cheaply is `unprobed`, never `up`; `ferry engines` shows `up, answered in 0.9 s`, `reachable only` or `unprobed`, and the engines file carries a `proof` field older readers ignore. A model call that times out, stalls, returns no tokens or errors marks that engine down for that agent, machine and model with the reason and a cool-off of 15 minutes, then 1 hour, then 4 hours, and the same order goes at once to the next engine the router allows, with each attempt a signed `engine-fault` ledger line under the order's id. An order goes through at most 3 engines (`max_engines_per_order`); then its claim is released with a note naming each engine and why. After 2 failures in a row (`engine_alert_after`) the master gets one signed question per engine per cool-off, naming the engine, model, machine, error and affected orders, with Block, Retry now and Keep re-routing; an order that exhausts its engines gets one too. A claim whose worker process is gone, or that has outlived the run timeout plus `claim_grace_secs`, is released back to the queue with a note; nothing is deleted. `ferry watchdog` reads engines the same way and reports "says up, but recent runs failed" as a fact. See docs/ENGINE_SETUP.md and docs/WATCHDOG.md.
An order can carry files. `ferry channel order --file PATH[=NAME]` (repeatable) copies each file into the channel under `files/orders/<id>/` and writes `{name, size, sha256}` for each into the order's payload, so the order's signature covers the hashes and a peer that has never heard of files still verifies the order. The worker does not claim the order until every file is in the channel with the signed hash (it holds, signed and readable, with `waiting for files to sync`, and says so plainly after 10 minutes without claiming); a file that does not match is never used and the hold names it. Once claimed, the files are copied into the order's working directory under `.ferryman-order-files/` (git-ignored), the agent's prompt says where they are and what they are, and the worker signs a receipt. Files over 50 MB each or 200 MB per order are refused when issued, secret-looking files (`.env`, private keys, credentials) are refused with a pointer to `ferry channel secret`, and the master can change the limits and the 7-day retention in the signed policy with `ferry channel files policy`. `--seal-to AGENTS` encrypts the files to those agents with the same sealing as secrets. The files of an order that has been done or reviewed for longer than the retention period are removed by a worker, with a signed record and a ledger entry written first; `ferry channel files list|show|gc` shows and does the same. The dashboard's Tasks page gains a New order form with a file picker and shows each task's files (size, hash, delivered or not); Telegram order cards show the file names. The channel's git backstop no longer commits `files/`. See docs/ORDER_FILES.md.
Every machine now has the same layout, one command puts it there, and `doctor` and the watchdog check it. `ferry fleet setup [--dry-run] [--service]` makes `comms/`, `repos/` and `work/` under the ferry root, gives every live project its own clone in `repos/<project>` (`ferry repos sync`, the clone URL set per machine with `[git] remote_template` in the root's `ferry.toml`; credentials stay in the machine's credential helper or ssh alias and Ferryman never stores a token), picks the machine's one worker identity, and prints the exact signing lines only the project master can run. A person's working copy is never the worker's checkout and is never moved, cleaned or fetched into: the managed clone becomes the worker's `repo` and the working copy is noted as a `human_checkouts` entry. With `--service` it installs the one worker as a service (a systemd user unit, or a Scheduled Task with a PowerShell wrapper that loads `service/worker.env`), turns every other worker service off without deleting it, and runs Syncthing as a service of its own so it outlives the worker. `ferry agent run --all-projects` and `ferry doctor` notice another live worker or service, and a second all-projects worker is refused unless `--allow-second-worker`. `ferry doctor --layout [--fix]` runs six checks (folders, repos, one worker, service, stale locks, Syncthing scope); the watchdog gains one fixed action, `repair_layout`, which runs `repos sync` and sets dead locks aside, and never signs. Only channel folders under `comms/` are ever shared by Syncthing: Ferryman now refuses to create a Syncthing folder at the root, above it or under `repos/` or `work/`, and `doctor --layout` flags one already configured. For that reason hand-ins (v0.5.27) now live in the channel, at `handins/`, instead of in `work/<project>` shared as `<project>-work`, which put the task worktrees in a shared folder; `doctor --layout --fix` stops sharing the old folder and deletes no file. A project's master can take it out of the swarm with a signed `swarm = off` (`ferry root swarm off --project P`, or the dashboard's Teammates page), and a machine can exclude one for itself (`ferry root swarm exclude P`); `repos sync`, `--all-projects` (including a worker already running) and `team approve --all` all honour both. A fleet worker reads a project's own policy from `ferryman.toml` `[policy] files` or `.ferryman/POLICY.md`, capped at 12 KiB a file and 32 KiB in all, and puts it in that project's prompts only; `--dry-run` and `ferry route explain` show `policy: <files>`. `ferry enable` refuses to join a different channel than the one the root already files, or a Syncthing folder id registered elsewhere (`--force-new-channel` overrides), takes `--no-enrol`, and makes a managed clone for a newly enabled project. Also new: `ferry root attach`, `ferry team revoke --name N --all [--as master]`, `ferry order reoffer <id> --to <identity>` (a signed offer beside the order, which stays as it was) and `ferry order release --held-by <identity> --all` (the master releases a retired identity's stale claims; nothing is deleted). See docs/FLEET_SETUP.md and docs/LAYOUT.md.
Reviewed and hardened before release. A project's remote can now be a SovGit one: `[git.remotes] quantly = "sov://bucket/quantly"` in the root's ferry.toml, with `fallback_template` as the GitHub fallback; `repos sync` says when `git-remote-sov` is missing and skips instead of failing, and fast-forwards only Ferryman's own clean clone. The signed `SWARM` file carries a number that only goes up, so an old copy put back or a deleted file does not switch a project back on. An offer is signed with its project and is refused when copied across projects or dated in the future. The Syncthing guard follows `..` and symlinks and keeps `service/` private. `fleet setup` refuses odd names and paths (control characters, quotes, `$`, `%`) in the files and commands it writes, a project's `ferryman.toml` is read to a cap, and a worker kills a leftover engine only when its command line is plausibly the engine.
## v0.5.27 - 2026-10-08
Agents hand work in; only the head pushes (ADR 0023). This switches on only once the master has named a head: a project with no head behaves exactly as before. A worker that is not the project's head no longer pushes a finished branch: it writes a signed patch (`handin.patch` and `handin.json`) into the project's work folder, which Syncthing shares as `<project>-work` with the same machines as the channel, and the result says where it is. The head runs `ferry work list`, then `ferry work accept <order>` (it checks the signature and checksum, applies the patch with `git am` so the worker stays the author, and pushes) or `ferry work reject <order> --reason ...`. `ferry work hand-in` makes one by hand. A head with a `push` remote still pushes as before; worktree mode with no remote now hands in instead of leaving the work on one disk. The first worker start after an update makes the work folder and shares it; nobody runs anything. `ferry doctor` gains `work_folder`, `handin_mode` and `tokens` (missing, orphaned or over-scoped GitHub tokens, judged from names and recipients without opening any), advice only; `doctor --fix` creates the work folder. The self-improvement prompt no longer talks about pushing.
## v0.5.26 - 2026-10-07
An owner can now open any project to suggestions from outsiders and their AI agents, under the owner's own terms. `ferry suggestions open --inbox github:owner/repo --terms TERMS.md --publish` signs an offer in the project's channel (master-signed, with a sequence number and a machine-local high-water mark, like the focus and the engine policy) and commits a join page - a README section, an `AGENTS.md` for agents, the terms, a machine-readable offer, and one schema per kind of suggestion - to a public inbox repository. Strangers never join the private channel or Syncthing: a contributor runs `ferry suggest join <invite>`, reads the terms and types `I agree` (a person's own script may pass the terms' sha256 instead, recorded as such; an agent must show the terms to its person), then `ferry suggest new`, `status`, `reply` and `withdraw`; each suggestion is signed with their own key and posted as an issue with their own GitHub token, which is never stored or shown. The owner's workers take each issue in on the next improve pass: one that does not verify, or is over a limit (3 open each, 1 new a day by default), is labelled invalid with a comment saying what to fix and never goes to a model; the rest are read by a cheap `http` text model with no tools, the contributor's words quoted as data, and its strict-JSON verdict goes to the owner as a signed question with Accept, Decline and Ask more, from `ferry suggestions pending`, the dashboard's Suggestions page or Telegram. Accepting makes an ordinary signed order tagged `suggestion`; shipping adds a credits order; every step is a line in a signed ledger that keeps the contributor's signed agreement as the legal record. `open` refuses the untouched draft terms template (however it is spaced, cased or dressed up) unless told otherwise. Everything a stranger or the model wrote is shown to the owner, and handed to the builder, quoted as untrusted data; hidden and direction-changing characters are refused in suggestions and terms; per-contributor limits follow the GitHub login as well as the key; and an inbox is read in bounded passes. See docs/SUGGESTIONS.md.
## v0.5.25 - 2026-10-07
`ferry watchdog` notices when a machine stops doing its job. Code gathers the facts - whether Syncthing is running, whether every channel is the folder Syncthing syncs, whether the worker services are up, and a real one-token request to every engine - and a local decision model (Ollama 0.35+, `nimble` or `tev1:4b`) picks one of five fixed actions: nothing, start Syncthing, repair channels, restart failed workers, or alert. Every pick is checked against the facts before it runs, and rules decide when there is no model. Reports go to the fleet folder; `ferry watchdog --show` prints every machine's. See docs/WATCHDOG.md.
NVIDIA engines are probed with a real one-token request by default. NVIDIA's model list includes models a given account cannot run, so the old probe reported such a model up while every request to it timed out.
## v0.5.24 - 2026-10-06
A channel that Syncthing does not sync is repaired instead of ignored. A machine could hold two copies of one channel, one that Syncthing syncs and one that its worker reads; orders sent to it and results from it never crossed, while every check said healthy. The worker now checks every channel when it starts and repairs a split itself: it copies the files that only the unsynced copy has into the synced one (overwriting nothing) and points `bridge.toml` at the synced folder. Syncthing is never changed and nothing is deleted. Anything it cannot repair is named in its startup line as NOT SYNCED. `ferry doctor` now marks a split as FIX rather than a note, and `ferry doctor --fix` runs the same repair. `ferry channel send` says NOT SENT when a message cannot leave the machine, instead of a receipt that reads like delivery.
`ferry rescue` is a break-glass way back into one of your own machines when the channel itself is what broke: a live terminal from anywhere, for as long as a person on that machine keeps it open. They approve every join, only your own machines' keys may ask, nothing is installed or listening until `ferry rescue open`, and it ends on `exit` or after 30 minutes. It does not depend on Syncthing: each machine keeps its own copy of the keys, and the join line is printed for a person to pass on. See docs/RESCUE.md.
## v0.5.23 - 2026-10-06
Giving an agent a key is now easy. The dashboard's Vault page, which said "Not built yet", seals keys: type a name like `DEEPSEEK_API_KEY`, paste the key into the hidden box, tick who can use it, press Seal. It lists what is sealed (never the values) and confirms with the name, who can use it and who sealed it. A key pasted into the name box is refused and nothing is saved, so a key can never become a file name that syncs in plain text.
`ferry channel secret set` is the same from a terminal: `--workspace` works before or after `set`, the name and recipients are checked before the key is asked for, the prompt says where to paste and that nothing will show, pressing Enter with nothing pasted stops with a message, and it ends with `done: NAME is sealed`.
Docs: UPGRADING.md explains why a machine first set up with an older Ferryman can sit idle after upgrading, and how to move its channel folders safely.
## v0.5.22 - 2026-10-06
An order addressed to a machine now reaches the agents on it. Orders sent to `grouchly` were offered to nobody, because a worker only matched its full identity (`ichabod-grouchly-cline`). A worker now also takes an order addressed to the machine it runs on, and once one of that machine's agents claims it, that agent holds it. The first part of an agent's name is the identity, so an order to `ichabod` still means exactly `ichabod`.
## v0.5.21 - 2026-10-06
The swarm spans every project: one worker serves every project it belongs to, the router knows what each model is good at (Haiku before Sonnet on Claude, never Opus in the background), and a signed focus list gives the projects that matter most the larger share of improve time and first claim on workers.
The router now knows what each model is good at. A built-in table of model families (Claude, GPT-5, Gemini, DeepSeek, Nemotron, Qwen, GLM, Llama, Mistral, Kimi) gives each one a starting success estimate for each kind of work, so Haiku starts well on chores, Nemotron on docs but not on review, and a 7b Qwen on nothing. A model the table does not know is scored by its size class exactly as before, and the ledger still moves every estimate. Review and plan now need 0.80 and large work needs 0.05 more (a threshold you set is used as set). Among engines that cost the same, a free one goes before a subscription before a paid one, then the one more likely to succeed. `ferry route simulate` and `explain` say where each estimate came from. A subscription is priced per request against its weekly cap, so when two subscriptions both clear the bar the bigger cap (Haiku) takes the work and the smaller (Sonnet) only gets what it does not clear, and Claude Opus is not picked for background work unless the policy names it.
The swarm knows which projects are the focus. The master signs a tier for each project - focus, normal, background or paused, each with an optional expiry - in the home channel, with a sequence number and a machine-local high-water mark so an older copy cannot roll it back. `improve run` visits projects in that order and shares its weekly budget by weight (focus 12, normal 3, background 1, paused none), workers look at focus projects first, and the width of improvement orders follows the tier. A person's own orders are never held, self-improve stays a per-repo switch and an archived project gets nothing. `ferry focus show|set|suggest|clear`, the dashboard's Focus page and Telegram's Focus button change it; `suggest` proposes tiers from git activity, open and failed orders, gathered evidence and staleness, and signs nothing. See docs/FOCUS.md.
One worker can now serve every project it belongs to. `ferry agent run --all-projects` runs as the identity and engine config of the `agent.toml` it is started next to (or `--workspace`), for every project in the ferry root where that identity is on the channel's roster and may work: one set of engines and router, and one `max_parallel` shared across all of them. A project it cannot serve (not enrolled, no checkout here, revoked) is named with the reason and the fix, and `--dry-run` prints `would serve as <identity> with engines ...` or why not, per project. `ferry team approve <name> --all` enrols one existing identity in every project the signing master owns: the roster entry with the same public key and the master's signed grant, per project, idempotent, reporting added, already there and skipped (not master); it needs the master's key exactly as before (and in each project it must be the key that project knows the master by), never lifts a revocation of the name or of its owner, and never replaces a different key. The identity's key is this machine's own or one given with `--key`, never read off a roster, which any member of a project can write. The worker puts its key only in a checkout that git-ignores `.ferryman`, asks again each pass whether a project is still one it may work in, and leaves out a project another worker holds rather than stopping the rest. `--comms` is unchanged, and now says so when the folder holds channels it cannot watch. Why `--comms <root>/comms` found one project as the wrong identity: it watches a folder of checkouts, each with its own `.ferryman`, and a ferry root's `comms/` holds channels only, so it found the one stray directory that had a `.ferryman` and ran that directory's own `agent.toml`. See docs/FLEET_WORKER.md.
## v0.5.20 - 2026-10-05
A revoked worker is no longer offered as an engine. Its last engines file stayed in the channel and still verified, so the router could send work to a retired name that nobody runs.
## v0.5.19 - 2026-10-03
A smart router: each piece of work goes to the cheapest engine that will most likely do it well - text, code, vision, image, video or audio, local or remote - and climbs to a stronger one only when a cheaper one fails. `routing = ordered` keeps the old strict order.
Smart router: every engine says what it can do, every order can say what it needs, and background work goes to the cheapest engine that will most likely do that kind of work well, moving up only after a cheaper one has failed at it.
### Smart router, part 2: choosing the engine
- **Cheapest sufficient engine.** Each engine gets a success estimate for the kind of work
(a prior from its size class and matching strengths, updated by its own verified and
refuted results with a 14-day half-life) and an expected price (declared price, or an
assumed frontier list price when none is declared - unpriced is not free; a scarcity price
for subscriptions that rises as the weekly cap runs down). The engines whose estimate
reaches the kind's threshold are sufficient and the cheapest wins; with none sufficient
the likeliest wins. The default threshold is 0.70 for docs, chore, tests, review, plan and
research (a medium engine such as a free nemotron starts at 0.70, so it takes that work
from the start and loses it when refuted) and 0.75 for code changes and media. Ties go to bias, then tier, then speed. Only engines that
have the modalities the work needs are considered: code-change needs a `cli` engine that can
edit, and an `http` engine (which is sent text only) is never given vision, audio, image or
video work. The policy's `never`, subscription protection and caps, `caps_usd`, `where` and
tiers still apply first, and the adversary is never routed.
- **Escalation.** After a result of this worker's own is refuted by its own evidence or sent
back with changes requested, the next attempt leaves that engine out and prefers an
estimate above the failed one's (worked out from this worker's own ledger, never read from
a result). It is a bar, not a rule: with nothing clearing it the likeliest engine takes the
order, and when the failed engine is the only one that can do the work it tries again
instead of the order waiting forever. A failure belongs to one agent's engine on one
machine, and only results this worker signed count.
- **Work that edits files needs `code`.** Improvement (build and chore) orders, orders with
`touches` or that require changes, and orders the rules cannot read are routed to an
engine that can edit files, not to a text-only one that would be refuted and demoted;
plans, reviews and research stay text.
- **Media evidence comes from attachments.** A media file or word merely named in the task
text (`update docs/logo.png`, `fix the transcription retry in src/lib.rs`) no longer makes
an order need vision or audio; only attachments, explicit `--needs`, or a kind that is
itself media do. A model's own modalities are ignored.
- **The model classifier is wired in.** For an improvement order whose kind the rules cannot
tell, a worker asks a model once (cached, 45 second timeout, an `http` engine only, never
an agent with tools), under the same policy rules as before.
- A person's order that carries a screenshot (any modality beyond text and code) is routed
by the router, not the operator's own engine order.
- An endpoint on this machine is local and free only when nothing says it is a gateway
(OmniRoute, a route) or paid (`prepaid`, `subscription`, `free-tier`); how an engine is
paid for is decided before whether it is local.
- The mixed-fleet warning now says a v1-only policy cannot carry `routing`, thresholds or
bias, so a project set to `ordered` and re-signed by v0.5.17 reads as smart.
- **Learning.** Verified and refuted results are counted per (engine, kind) in the engine
ledger, under its existing lock, and published in the signed inventory (v2-only).
`ferry engines` shows each engine's best kinds with their success rate.
- **Policy: `routing`, `thresholds`, `bias`.** `routing = smart` is the default;
`ordered` is exactly the previous strict prefer-list behaviour. Set them with
`ferry engines policy set --routing smart --threshold docs=0.7 --bias 'nemotron*=3'`
(`--threshold none` / `--bias none` clear them) or in the dashboard's Engine policy panel.
They are signed in the policy's v2 view only, so v0.5.17 machines still verify the file.
- **Explainability.** Every decision is recorded beside the step and in the result: each
candidate with its estimate and price, why the others were out, and a one-line reason
(`nvidia: free, p 0.80 for docs >= 0.70, cheapest sufficient`). `ferry route explain
<order>` shows what workers recorded and what would happen now; `ferry route simulate
--kind docs --size small [--needs vision]` shows the ranking without running anything.
The dashboard shows the reason on each order card, every candidate in the order's drawer,
and a Routing panel that runs the simulation (`GET /api/route/simulate`);
Telegram's review card carries the one-line reason.
- Orders from people keep the operator's own engine order unless they need a capability
only the router knows engines have (vision, audio, image, video).
- **Docs:** a "Smart routing" section in `docs/ENGINE_SETUP.md`, with an example for
NVIDIA first while free, then Sonnet, then Haiku, local models eligible.
### Changed
- `ferry engines` reads the project the current directory is inside as well as the ferry
root's projects, so it no longer says no worker has published when run inside an attached
project (the same project under another spelling of its path - `..`, case or slashes on
Windows - is not counted twice). Other fleet-wide commands resolve projects as before.
### Smart router, part 1: what engines can do and what orders need
#### Added
- **Engine capability profiles.** Each engine has `modalities` (`text`, `code`, `vision`,
`image`, `video`, `audio-in`, `audio-out`, `embed`, plus any value a newer peer
publishes), `strengths`, `context_k`, `cost` and `local`. Declare them in `agent.toml`
(`engine.<name>.modalities`, `strengths`, `context_k`, `cost_per_call_usd`,
`cost_per_mtok_in_usd`, `cost_per_mtok_out_usd`, `local`); anything you leave out is
guessed from the engine's name, model, command and base URL, and a declared value always
beats a guess. An engine with no cost is *unpriced*, not free. Profiles are published in
the signed v2 engine inventory (the v1 signature is unchanged, so v0.5.17 peers still
verify the inventory), shown as a modalities column and `can:` line in `ferry engines`,
and as a "Can do" column and capabilities panel in the dashboard.
- **`needs` on orders.** `ferry channel order --kind <kind> --needs <modalities>
--size <small|medium|large> --min-context-k <n>` states what an order needs. It is
signed with the order; orders that do not use it are byte-for-byte unchanged.
- **Order classifier.** Explicit needs first, then deterministic rules over the task text,
attachments, file types and size (each rule has a confidence), then - only when the rules
are not sure - a model fallback. The result records its source (`explicit`, `rules`,
`model`), confidence and reasons.
- **Model-assisted fallback.** The cheapest text engine answers, local preferred; a
subscription engine is used only when the policy's `subscription_roles` includes `chore`.
The verdict is cached per order id in a local, unsigned file under
`routing/classify/` (a synced file would have two writers); failures are retried after
an hour and never block the order.
- **`ferry route classify <order>`** shows the needs, source, confidence and the reasons.
- The dashboard shows each task's needs and, in the drawer, how they were decided.
- **Docs:** a capability and routing section in `docs/ENGINE_SETUP.md`, including
ComfyUI, whisper and TTS engines as clearly marked examples.
## v0.5.18 - 2026-10-02
A second model of your choice challenges the work at three moments - before a contract locks, when an order fails twice, and before an improvement is done - and in blocking mode nothing goes live until it has. Orders can share signed interface contracts and declare the files they touch, and a team preset runs cheap models in parallel swarms, including Claude Sonnet and Haiku when you opt a role in with a weekly cap.
### Added
- **Typed result schemas.** `--result-schema <file.json>` gives an order a shape its result
must have: `type` (string, number, integer, boolean, array, object, null, any),
`required`, `properties`, `items` and `enum`, nested. Violations are path-qualified
(`result.user.id: expected integer, got string`) and go through the existing contract
violation path, so they block acceptance. Orders without a schema are unchanged.
- **Interface contracts.** A signed `contracts/<name>@<version>.json` that any member can
propose and the master (or an `improve` delegate) locks with a separate signature.
A locked contract never changes - a change is a new version - and a forged, unsigned or
edited-after-lock file is ignored. Orders say `--interface name@version:provides|consumes`;
a worker will not start one whose contract is missing or not locked and records why
("waiting for contract user-api@1 to be locked"). Consumers get the locked shapes in
their engine prompt; a provider's result is validated against the locked response. The
master is asked once, however often it is proposed, with Lock / Reject buttons in
Telegram. `ferry contract propose | lock | reject | show | list`; a Contracts page on the
dashboard with status, shapes, the orders on each side and a Lock button for the master.
`interface::pending_locks` and `interface::orders_for_interface` expose what is waiting
to be locked, and which orders depend on it, to a reviewer.
- **File-overlap locks.** `--touches <glob>...` and `--allow-overlap` on an order.
Issuing warns about overlapping open or claimed orders, the dashboard marks them, and a
worker will not claim an order that overlaps one currently claimed unless
`--allow-overlap`, recording why and moving on. After the commit the result records
`touched_files`, with a reviewer note (not a refutation) for any outside the declared
globs.
- **Adversary gates.** A separate model challenges the builders' work at three moments:
before an interface contract locks, when the same order fails twice, and before an
improvement is called done. New engine-policy role `adversary` (old signed policies
still verify) and mode `--adversary off|advisory|blocking` (default `advisory`).
It is never the builder's own engine unless that is the only one allowed (then the
finding says `same engine`), prefers a different model family, and `recommend()` picks
one with a reason. Findings are signed files in `<channel>/adversary/`, one per subject,
revision and moment, and the cost goes in the ledger; unparseable output is a `concern`,
never a silent pass. A deterministic test-tampering scan of the order branch's diff runs
before the adversary at the second failure, and a Block or any High hit sends the task
back with the finding in the next attempt's prompt (the master is asked once). In
`blocking` mode a Block stops Lock, the review engine's key and auto-merge until the
master signs an override. `ferry adversary show | list | override | check`; findings in
`ferry improve status/report`, the review and contract output, the dashboard (policy,
review cards, Contracts, `GET /api/adversary`, `POST /api/adversary/override`) and
Telegram cards (an Override button only for a Block, under the `improve` / `review`
delegation scopes). See docs/ENGINE_SETUP.md.
- **Plan on high, build on medium, swarm the cheap work.**
- *Effort per role.* The engine policy gains `effort` (defaults: plan, review and
adversary high, build medium, chore low). `{effort}` is filled in an engine's `args`
like `{model}`; `engine.<name>.effort_args` adds arguments per level; an HTTP engine
gets a `reasoning_effort` field only with `supports_effort = "true"`. The effort used
is recorded on each step beside engine, model and machine. `ferry engines policy set
--effort role=level`.
- *Model size class.* `engine.<name>.class = small|medium|large`; without one, a tested
guess from the model name. Shown by `ferry engines`, published in the signed
inventory, usable as a `class:small` selector.
- *Swarm width.* `max_parallel` in `agent.toml` (default 1, exactly as before): a work
pass claims up to that many orders and runs them at once, each in its own worktree,
never two with overlapping `touches`, and honouring contract and adversary holds. The
policy's `width` caps how many improvement orders of a role the fleet has claimed at
once, counted from current non-stale claims (`--width role=n`). The engine ledger,
step log, agent profiles and worktree creation are serialised so concurrent orders
lose no update; an engine that reports quota mid-swarm lets in-flight orders finish and
new claims skip it.
- *Team preset.* `ferry engines policy team [PROJECT | --all] [--accept]` proposes, from
the fleet's signed inventories and with a reason per choice, a large judge to plan and
review (high effort, width 1), mid-size engines to build (medium, width 3), the
smallest to do chores (low, width 4) and a large judge of another family than the top
builder as the advisory adversary. The dashboard's Engine policy panel and the Telegram
Engines menu show it with an Accept button; the dashboard also has effort and width per
role (`POST /api/engine-policy/team`, `/settings`).
- *Subscriptions stay off.* New `subscription_roles` (`--allow-subscriptions-for
build,chore`) lets a role use a subscription, and only an engine with a
`weekly_requests` cap; anything else stays blocked, with a warning on every surface.
Old signed policies still verify. See docs/ENGINE_SETUP.md, "Team preset and swarms".
### Fixed
- **A result's commit can no longer be an argument to git.** The adversary's diff scan,
auto-merge and the worktree helpers took `worktree_head` (and other peer- or
channel-derived revisions) straight into `git fetch`, `git diff` and `git merge-base`,
where a value such as `--upload-pack=<command>` or `--output=<file>` is an option. Only a
full object id (40 or 64 lowercase hex digits) is accepted now, verified to be a commit
here, and it must be the order branch's tip or descend from the base the order started
from; anything else is an Unscanned concern and nothing is fetched. Every git call that
takes a revision, ref or remote derived from synced data puts `--end-of-options` before
it (or refuses a value that starts with `-`). A gated order with no readable commit in a
git workspace is an Unscanned concern, not a clean pass, and a branch read without a
named commit is fetched first so a stale local tip is not read instead.
- **An unreadable adversary reply is no longer a finding.** A reply with no readable
verdict used to be recorded as a signed Concern, which is an eligible word that satisfies
`blocking` mode for a revision the adversary never judged; and "the last JSON object
wins" let a `{"verdict": "pass"}` echoed from a diff decide. Only the one fenced `json`
block at the very end of the reply, with a `verdict` and `findings`, is read; anything
else records nothing and is a failure the next pass retries (the next engine is asked
first). The deterministic tamper-scan floor now also counts at the pre-done moment, as a
Block only, so a diff that deletes tests blocks the review key even when no engine could
be asked.
- **A finding counts for the result it judged, not for a revision number.** A contract's
"round" is an ordinal over its verified provider results, so deleting an older result
renumbered the rounds and an old Pass at round 2 then covered a different, newer result.
A finding now carries a signed `result_digest` (a hash of the result's order, revision
and signature); a contract-lock or pre-done finding counts only when it names the result
that is at that revision now. Rounds are for display and file names only.
- **The adversary's policy is the master's alone.** Its mode, engine preferences, `never`,
allowed agents and weekly cap used to live in `ENGINE_POLICY`, which a delegate holding
`improve` can sign - so the one who could be checked could switch the check off, name
its judge, starve it with a `never`, `where` or a $0 cap, or have its findings ignored.
They are now a separate `ADVERSARY_POLICY` file honoured only under the master's own
signature (no delegation), with the same `seq`, per-machine high-water mark and
last-known-good protection as the engine policy and its own rollback question. An
`ENGINE_POLICY` file's adversary fields are dropped on reading, the engine policy's
`never`, `where` and caps no longer apply to the adversary, and the review pass runs the
adversary before the `where` check. `ferry engines policy set` gains
`--adversary-agents` and `--adversary-never` (they, `--adversary` and `--role adversary`
write the master's file); the dashboard shows the terms to everyone and changes them
only for the master; Telegram shows them and never signs them. Putting the engine policy
back to auto leaves the adversary's policy as signed.
- **Any member with an inventory is no longer an adversary.** A finding counted from any
signer that was not the builder and had published an engine inventory listing the engine
it named, and one adversary's Pass stopped every other adversary from running - so a
member the master never meant to give a vote could satisfy `blocking`. A signer now
counts only when the master's adversary policy allows it: it is one of the named
`adversary_agents`, or - with no list - its signed inventory lists an engine matching the
adversary's preference selectors and not named by the adversary's own `never` (so the
trust is "any member running an allowed adversary engine"; documented in
`docs/ENGINE_SETUP.md`). The not-the-builder rule stands, and the deterministic tamper
scan stays a floor anyone can record, as a Block only. Every eligible adversary now runs
its own pass - an agent is settled only by its own finding - and a Block from any of them
dominates.
- **A mixed fleet says what it trusts.** `docs/ENGINE_SETUP.md` has a new section, "Mixed
fleets and what a fresh machine trusts": rollback protection is per machine, so a fresh
machine takes the first valid file it sees; a v0.5.17 machine reads only the v1 view, has
no memory and never runs or waits for the adversary (so `blocking` binds only machines on
this release); a policy signed by v0.5.17 has no sequence number. `ferry engines policy
show` (and `--json`) and the dashboard now warn when the policy in force is such a
v1-only file while a member whose inventory carries a v2 signature is on the roster, and
signing the same policy again now upgrades a v1-only file instead of reporting "already
so".
- **The adversary's files cannot collide, and its displays are not capped.** A finding is
now `adversary/<subject>-r<revision>-<moment>/<signer>.finding.json`, a master's override
`<signer>.override.json` and a waiver `waiver.json` in the same directory; a signer's name
must be a path-safe component with no `.`, so no name can be mistaken for another kind of
file (the old layout reserved the word `override` by hand). `ferry adversary list`, the
dashboard, the Telegram view, the summary and the weekly report take their subjects from a
listing of those directory names rather than from the 500 newest findings, so an old
Block cannot fall off a display. A worker keeps off the files of a sent-back order only
while its rework is in play (a live claim, or a verdict newer than the stale window), so
an abandoned rework no longer holds them for ever. The file-overlap matcher refuses a glob
or path over 1024 bytes: it overlaps everything and matches nothing.
## v0.5.17 - 2026-09-29
You choose which model does self-improve and on which machine, fm recommends one that spares your subscriptions, and nothing goes live without both an engine review and your approval. OmniRoute works as an engine.
### Added
- **Engine policy: which engines do background work, and where.** A master-signed
`ENGINE_POLICY` per project (or `--all` of them) says, for each background role -
`plan`, `build`, `review`, `chore` - which engines are preferred in which order, which
are `never` used, which machines may run self-improve (`where`), optional weekly
dollar caps per role, and whether subscriptions are protected (on by default).
Selectors match an engine name, a model glob (`nvidia/nemotron*`), a paid class
(`paid:free-tier`) or an endpoint host. Unsigned, edited or forged policies are ignored
exactly like `SELF_IMPROVE`. Background work is improvement orders and the loop's own
planning and review; orders a person gives are untouched unless the policy says
`never_applies_to = "all"`.
- **Auto, when no policy is set.** Local > free tier > capped prepaid > unknown >
uncapped prepaid; never a subscription while protected. Ties go to trust, then cost
per verified result, then operator order; plan and review prefer a judge. An unmarked
`claude` or `codex` CLI counts as a subscription. `ferry engines policy recommend`
prints the proposal with one reason per choice ("nemotron first for build: free-tier,
12 verified, 0 refuted"); `accept [--all]` signs it.
- **No fallback.** With nothing the policy allows available, background work waits -
never on a blocked engine - the reason is recorded, and the master is asked once per
role per week, with buttons on the phone. A worker outside `where` does not claim
improvement orders. A free tier that returns a payment or quota error, or reports a
cost, is flagged in the ledger and the published inventory, ranked down, and the
master told once.
- **Who did what.** Each improve step (gather, plan, build per order, review) is
recorded, signed by the agent that did it, with engine, model, machine and cost.
`ferry improve status` and `ferry improve report` show them with spend per engine;
`ferry engines` ends with each project's effective policy - each role's engines in
order and every blocked engine with the reason.
- **Everywhere.** `ferry engines policy show | recommend | accept | set | clear`; an
Engine policy panel on the dashboard's Teammates page (accept, edit prefer / never /
where, back to auto, for one or all projects); Accept recommended, Block and Move to
top in Telegram's Engines menu under the `improve` delegation. `ferry improve on`, the
dashboard and Telegram offer the recommendation when a project has no policy.
- **Pick two engines, simply.** The dashboard's policy panel opens with two dropdowns -
"Improvement engine" (plans and builds) and "Review engine" - each engine shown with
its paid class and the recommended one marked, and a "Use recommended" button; the
full prefer / never / where editor moved under "Advanced". Telegram's Engines menu
picks each with two buttons. CLI: `ferry engines policy set --improve <sel> --review
<sel> [--all]`.
- **Two keys before an improvement goes live.** An improvement is "approved for live"
only with both: a signed verdict accepting it from the policy's review engine, on a
result whose evidence passes verification and that no verifier refuted; and a signed
approval from the master (or their `review` delegate acting on the master's button).
Neither alone is enough - `submit_review` refuses an agent's accept, and the master's
until the engine's key is there - and a blocked or unavailable review engine holds
and asks, never skips. Even with both, only low-risk work under auto-merge (below)
merges on its own: the "ready to merge" notice and the report's list now need both keys. `ferry improve pending | approve |
send-back`, a "Waiting for your approval" list on the dashboard (diff stat, evidence,
the review engine's summary, Approve / Send back), and the same on the phone.
- **OmniRoute as a first-class engine.** `provider = "omniroute"` (or a base URL on port
20128) is recognised. The probe lists its models and combos and each combo's steps;
a route is free, subscription or unknown by where it ends, so protection and `never
claude` see through the gateway. Its combos and free models are published as engines
of their own ("OmniRoute: free-stack") for the dropdowns, a free combo ranks as free
tier, and its per-request cost header is counted. `ferry engines policy recommend` and
`ferry enable` point out a local OmniRoute nobody uses yet.
- **Auto-merge low-risk work, only after both keys.** `auto_merge = "none" | "low-risk"`
in the engine policy, `none` by default. With `low-risk`, an improvement holding both
keys is merged by the worker that built it when every changed file is docs (`*.md`,
`docs/**`, license text, Rust comment-only changes), tests (`tests/**`, `*_test.*`,
`test_*.*`, `*.spec.*`, Rust changes inside a trailing `#[cfg(test)]` module) or
dependency versions (lockfiles changed in place; `Cargo.toml`, `package.json`,
`pyproject.toml`, `requirements*.txt`, `go.mod` where only versions change) - read from
the branch itself. Code or config still stops at "approved, ready to merge". The loop
signs a `merge-authorized` record; the worker re-checks both keys, the reviewed
commit and every file, merges (fast-forward, or a merge commit), pushes only if it
already pushes for the project and never with force, and records the merge in the
ledger and on Telegram. A conflict, a moved branch, a dirty checkout or a refused push
falls back to the master with the reason. `ferry engines policy set --auto-merge
low-risk [--all]`, a checkbox on the dashboard, a button on the phone.
### Changed
- An engine's accepting verdict on an improvement is recorded as a signed engine
review and a recommendation, never as an acceptance; approving is the master's.
- Improvement orders now go to engines in the policy's order (auto when none is set),
not the operator's engine order; a subscription engine no longer takes them unless a
policy allows it. Free-tier and local engines count no spend unless the provider
reports a cost.
- Engine inventories carry the endpoint host, whether a weekly cap is set, this week's
requests and spend, and any free-tier flag. A worker on an older version sees a newer
worker's inventory as unverified until it is upgraded, as with engine trust in 0.5.16.
## v0.5.16 - 2026-09-29
fm checks what its workers did instead of trusting what they said, and it gets better on its own: a weekly self-improve loop per repo, engines that fall back when one runs out, delivered and read receipts, and every project from one Telegram chat.
### Added
- **Delegation: an agent acts for the master by its own key, never their password.**
The master signs, once, that an agent's key may act for them in named scopes -
`orders`, `review`, `improve` - per project, until revoked or an optional expiry:
`ferry team delegate telegram-grouchly` (every project you master, or `--project`),
`ferry team undelegate`, `ferry team delegations`, or the dashboard's Teammates page
("Let ... act for me in every project", "Revoke everywhere"). Verifiers accept an
order, a verdict (including master-only approvals), a self-improve switch or an answer
signed by someone other than the name it carries only under a valid, unrevoked,
unexpired, master-signed delegation for that scope and that exact key; the record reads
"josh via telegram-grouchly". An order signed by one agent in another's name no longer
verifies without one. Revoking withdraws what the delegate signed, too.
- **`ferry telegram`: every project from one chat, by buttons.** A private chat or a
group (`TELEGRAM_CHAT_ID`), usable only by the ids in `TELEGRAM_APPROVERS`; anyone else
is logged and ignored. A menu - Projects, Engines, Tasks, Self-improve - with a project
picker, the signed engine inventory, tasks with their stages, and self-improve toggles
per project plus "On for all my repos". Free text is a signed order to the chosen
project (default `ferryman`), and one status message is edited as it goes sent,
delivered, read, claimed, done; the result follows. Work that needs you arrives with
Approve / Send back / Details; Send back takes the note as a reply. When no engine
anywhere can run an order it says so, and says again when one is back. `/start` and
`/menu` only show the menu. It signs as `telegram-<machine>` under a delegation and
needs no `FERRYMAN_OPERATOR_PASSWORD`. `ferry channel telegram` is unchanged.
- **The improve loop asks, and never merges.** `ferry improve plan` may record up to two
clarifying questions, and review records a "ready to merge" notice for each accepted
improvement, as signed `questions/<id>.json`. The bridge posts each with option buttons
or a reply in words; the answer is a signed statement from the master (or their
delegate), and next week's evidence reads it.
- **Improvements wait while you are at the machine.** A worker leaves unclaimed
`improvement` orders, and its hourly improve loop, until you have been idle for
`idle_after_secs`; orders you give directly are never deferred.
`defer_improvements_while_active = "false"` in `agent.toml` turns it off.
- `ferry improve on --all` / `off --all`, and the dashboard's "On for all my repos":
self-improve for every project you master.
- `ferry license register --email X --all` registers this machine in every channel under
the ferry root.
### Fixed
- `ferry license status` counted Syncthing conflict copies (`*.sync-conflict-*`) of a
device record as extra computers. They are ignored, and a record is counted once.
- One unreadable message envelope no longer hides a project's history. A 0-byte file
from a truncated write made `ferry channel log` and the dashboard fail for every
message; it is now skipped with a warning naming the file.
- **An order says whether it arrived.** A worker writes a signed, write-once
`delivered.<agent>.json` beside every order meant for it as soon as it sees one - even
while paused or held off by the governor - and a signed `read.<agent>.json` just before
it hands the order to the engine, or when `ferry channel work` shows it to a live
session. Each worker also keeps a signed `presence/<agent>.json` in every channel it
serves (machine, version, paused or held), rewritten at most every five minutes.
`ferry channel status` now lists each open order's furthest stage - sent, delivered,
read, claimed, done - with ages, warns when an order is undelivered after 5 minutes or
delivered and unread after 15, and names any roster worker or machine not seen in the
last hour. Receipts that do not verify are shown as unverified and count for nothing.
The dashboard's task list shows the stage too, and `/api/tasks` and `/api/roster` carry
it. Ten channels had silently stopped syncing to one machine for weeks; this is the
signal that was missing.
- **An agent can run several engines, and falls back when one runs out of credit.**
`engines = [...]` in `agent.toml` lists them in order, each a CLI or an
OpenAI-compatible endpoint, with a tier (`judge`, `build`, `chore`), how it is paid,
and optional weekly request or dollar caps; keys are only ever `secret:NAME` or
`env:NAME`. A 402, "insufficient balance", "usage limit" or long 429 marks that engine
exhausted until its reset, is not counted as an attempt, and the same order goes to
the next engine at once. With every engine out, the worker holds off and says why.
Engines are probed every ten minutes (models list or one token, plus DeepSeek and
OpenRouter balances), and each worker publishes a signed `engines/<agent>.json`.
`ferry engines` shows which engine works on which machine; `ferry mcp serve` gains a
read-only `list_engines` tool. A config without `engines` runs exactly as before.
grouchly's worker failed every order in a second on "Insufficient Balance" and could
not even read the order telling it to switch.
- **`ferry improve`: a weekly improvement loop per project, off until its master
switches it on.** `ferry improve on|off` (or the Self-improve button on the
dashboard's Teammates page, for the signed-in master) writes a master-signed
`SELF_IMPROVE` setting into the channel, so it applies on every machine; a setting
anyone else signed is ignored. `ferry improve status` lists every project with its
switch and last run, and the dashboard shows each worker's engines. `gather` collects the
week's evidence, `plan` has a judge-tier engine turn it into at most five ranked,
signed `improvement` orders (a builder plans when no judge is up, and the plan is
marked unreviewed), `review` judges the results through the usual review, and
`report` compares the week with the last. `ferry improve run` does whatever is due,
is idempotent and cheap, and can be run by n8n or cron, or hourly by the worker with
`improve = "true"`. Nothing merges; `ferry pause` stops it.
- **fm checks results instead of trusting them.** A worker records what git and its own
processes saw around each engine run - HEAD before and after, commits made, diff stat,
uncommitted paths, duration, whether commit hashes the answer names exist, and the
checks the order names (cargo, npm, pnpm, yarn, go, pytest, make; no shell) with exit
codes and output tails - as an optional signed `evidence` block in the result. Each
result is then verified, unverified or refuted, recomputed from those facts. A result
that claims changes with HEAD unchanged and no diff, names a commit that does not exist,
skips required checks, claims success over a failing check, or is empty, a refusal, a
redirect or all placeholders ("no output") is refuted: it never counts as done, never
satisfies a dependency, cannot be accepted, and shows as refuted in `ferry channel
status`, the dashboard, Telegram and the improve report. The verdict is a separate
signed `verification.<verifier>.<rev>.json` plus a ledger entry; the worker's file is
never touched. grouchly's headless worker had signed "no output" seven times, twelve
seconds after claiming, and fm counted it as success.
- **Engines that fabricate get demoted.** Verified, unverified and refuted counts per
agent and engine are kept in the signed `engines/<agent>.json`. Two refutations within
14 days drop an engine to chore tier, so it gets no build or judge work, and a worker
will not claim a build order with only a demoted engine. A demoted engine runs an
hourly canary (commit a known line in a throwaway repo, checked with git only); a pass
restores it. `ferry engines` shows trust, DEMOTED, and a tally of what other machines'
signed verifications found.
- **PROBLEMS.md is a list of claims, not a list of facts.** `ferry improve gather` reads
`PROBLEMS.md` in any letter case at each project root, one claim per section or list
item, as unverified. `ferry improve plan` sends a verification order for each claim
first (confirm or refute, with `file:line` citations that are checked to exist); only
confirmed claims reach the planner, and refuted ones are recorded in
`improve/claims.json` and not raised again.
- **Archived projects are left alone.** Self-improve is off for a project with a
master-signed ARCHIVED mark and cannot be switched on; `improve run`, `improve plan`,
`improve on --all`, the dashboard's and Telegram's "On for all my repos" skip it, and
`improve status` shows "archived".
### Changed
- An `improvement` order, or any order with `requires_changes: true`, is only accepted
with an evidence block. Workers need this release before their build work can pass
review.
## v0.5.15 - 2026-09-24
A worker works without waiting for approval, and approving anything else is one step.
### Changed
- **A worker needs no approval to work.** In a team that requires grants, every agent
waited for a master-signed grant for its role - including plain workers, which ADR 0014
already said should not. The first worker on a second machine sat idle for a day until
someone found the channel path, its public key and the role flags. Now a worker works
unless it, or the person who owns it, has been revoked; every other role still waits
for the master.
### Added
- **Approving an agent is one step.** `ferry team approve <agent>` signs the grant as the
master, reading the agent's key from the roster and its role from what it joined as.
The dashboard's Agents page shows what each agent may do and gives the master an
Approve button for any agent still waiting.
## v0.5.14 - 2026-09-23
Three faults found on the live fleet the day 0.5.13 shipped: a worker that could never
re-take its own task, a machine in daily use that never picked up work, and a sandbox
that could not see its own launcher.
### Changed
- **A worker shares the machine with you instead of waiting for you to leave.**
`pause_while_active` used to stop new work whenever anyone had touched the keyboard in
the last `idle_after_secs`, so on a machine somebody uses all day the window never
opened and the worker looked dead from the channel. Now, while you are at the machine,
it keeps taking work as long as total CPU use stays under `busy_cpu_percent` (default
50) - typing leaves most of a machine idle, and an agent that mostly waits on a remote
model barely touches it. When the machine is busy it waits, and says how busy. Set
`busy_cpu_percent = "0"` for the old behaviour.
### Fixed
- **A sandboxed worker whose command is a launcher on the host now runs it.** The
container got the workspace and the configured mounts, but not the launcher script
itself, so the image's entrypoint was handed a path that did not exist - the Node
image ran it as a script and died with MODULE_NOT_FOUND on every task. An absolute
command that is a file on this machine is now mounted read-only at the same path.
- **A worker that released its own claim could never hold the task again, and claimed it on
every poll.** A release counted against the agent forever rather than against the claim
it followed, and a claim file that already existed was never rewritten. A worker
releases its orphaned claims at every startup, so an order addressed to it stayed
Offered for good: one worker wrote 33,608 identical "claimed order" entries into the
signed ledger in a week, all of them synced to every machine. A release now ends only
the claim it came after, and claiming a released task writes a fresh claim.
## v0.5.13 - 2026-09-23
This release exists for one thing: archiving now reaches the whole fleet. In 0.5.12
`ferry root archive` wrote a flag into this machine's `.ferry`, and `.ferry` never leaves
the machine it is on - so a project archived on beastly was still live on grouchly and
everywhere else. Every machine has to run 0.5.13 to honour a fleet-wide archive, which
is why this is a version and not a quiet patch.
### License
- **Ferryman is now under the Usufruct License (UFL) 2.1, Seat-Limited.** It was adopted on
`main` as UFL-2.0 and updated to 2.1 (Section 2B, the license-text reproduction
carve-out). See `LICENSE`.
### Changed
- **An archive is now a signed `ARCHIVED` file in the channel, not a line in the local
index.** Syncthing carries it to every machine that syncs the channel, and carries its
removal back, so `archive` and `--restore` both travel with the project. `.ferry` no
longer has an `archived` field.
- **Only a project's master can archive it or bring it back.** The mark is signed, and a
machine honours it only when the signature is the master's, by the key the channel
knows the master by. A peer can write a file called `ARCHIVED` into the channel; it
cannot make any machine believe it. A mark signed by a member, lifted from another
project, or not signed at all is read as no mark. The master's `--restore` clears it.
- **A project with no master cannot be archived**, and says so, with the command that
names one (`ferry root master`). Archiving also needs the channel on the
machine doing it, since that is the only place the fleet would hear it.
### Added
- **`ferry root master`: one password, and you are master of every project that has
none.** `enable` never makes a machine the master where a person is present, and it
cannot sign as that person, so on a machine you use it left the role empty and
pointed at the dashboard - and nothing came back for it. On the machine this was
written for, 30 of 33 channels had no master and 30 had never seen the person's
public key. This goes through every project in the ferry root, puts your key on each
channel's roster, and declares you master where nobody is. A project with another
master, or that knows your name by a different key, is left alone and named.
`--dry-run` shows the plan without a password. `enable` now points here.
**And in the browser, unasked.** Opening the dashboard's team page signed in as the
master of the project on screen claims every unclaimed project in the ferry root for
that person - the human, never the machine - and lists each one it took. A person
who is not yet master anywhere gets a button for the same thing. Neither turns on
required grants in bulk: switching thirty projects to grants-required at once would
stop every agent working in them.
- **Head agents, named in plain words.** The master says it however they like -
"grouchly, you're head agent for now" - in the dashboard or in a message, and the
agent named runs `ferry channel head claim`. That finds the master's signed words
that name it and records them whole in the channel (`head/<agent>.json`), so every
machine can check them without trusting the agent. The newest words win, so naming
someone else replaces the head. `ferry loadmem`, `ferry channel status`,
`ferry channel head` and the dashboard's team page all show who it is and the
words it rests on. The dashboard now signs each thing a person says on its own
(`said/`), since a conversation file is signed whole by whoever wrote last. The
words are not parsed: anything the master signed that names an agent can back a
claim, so the page shows the words and gives the master a revoke.
- **The Telegram bridge signs as the person who runs it.** `ferry channel telegram
--agent josh` unlocks josh once at start, with the password, and holds the key in
memory for as long as the bridge runs - published to every roster, never written as a
key file. Before, it could only sign with a machine key, so every order and word
from the phone read as the machine's. Each message from the approver is also signed
on its own into `said/`, so "grouchly, you're head agent" works from Telegram too.
For an unattended start, FERRYMAN_OPERATOR_PASSWORD supplies the password.
## v0.5.12 - 2026-09-23
Your machines are you, a git account is proof of whose projects these are, and a
finished project can be put away without being thrown away.
### Added
- **A master claims their git account, and every project that account owns gets it**
(ADR 0022). `ferry team anchor claim` binds the master's Ferryman key to a GitHub
account by signing with an SSH *signing* key published on that account - one that
grants no access to anything, only proves the account is yours. Claim once and every
project whose remote that account owns is anchored; nobody else can become master of
them without controlling the account. One identity can hold several accounts.
Required for projects on git, optional off it. The agent loop and the dashboard
re-check at irregular 3-11 day intervals, jittered from the machine's own name so a
fleet does not hit the provider in step. When an anchor stops verifying the master is
**paused, never vacated**: after 72 hours of contradiction a paused master directs no
new work, and the fleet finishes what it already holds. A sale of the account is a new
owner, and a new owner claims for themselves.
Verification reads `api.github.com/users/<login>/ssh_signing_keys`, not
`github.com/<login>.keys` - the second lists *authentication* keys, which grant push,
and a proof key must be one that grants nothing.
- **`ferry root archive`: finished is a state, not a deletion.** Between `adopt` and
`forget` there was nothing for a project that is simply over. Archiving keeps the
channel, its signed history, and its sync; it only stops the project being offered as
somewhere work happens, and drops it from the anchor spread. `--restore` takes it back.
`ferry root show` always says how many it is hiding; `--all` shows them.
- **`ferry root forget`, and `--gone`.** The index could be added to and never
subtracted from, and the gap did not show: `show` hides an entry whose channel has
gone, so a dead entry vanished from every listing while staying in the file forever.
`forget` touches the manifest and nothing on disk. `--gone` clears every unreachable
entry, and prints where each one pointed so a dead test run can be told from a project
you are about to stop tracking.
- **`docs/FERRYMAN_IN_EVERY_PROJECT.md`**: the page a project reads to *stay* on
Ferryman. The eight ways a project gets stranded, what each looks like, and the command
that fixes it - every command run against the binary before being written down.
- **A second machine can join as you, rather than as a stranger.**
`ferry team invite create --as-identity josh --machine beastly` invites one of your
own machines onto a project you are already on. It joins as `josh-beastly` and
inherits exactly what `josh` can do - no more, and nothing the master has to approve.
Until now every invitation needed the master's signature and produced a separate
member, so a person with two laptops was two people on the roster with two grants to
keep in step, and revoking them was two jobs, one of which was easy to forget.
The inheritance is a signed file of its own, `owners/<agent>.json`, sitting beside the
grants: a statement by an established identity that a named key is one of their
machines. It confers nothing by itself. `is_granted` resolves a machine to its owner
and answers with the owner's grant, so revoking the person takes every machine of
theirs dark in the same moment - the answer was never stored on the machine.
The claim is signed by the owner, not by the master, because it is a claim about your
own keys. That is worth exactly what your own access is worth: creating one of these
invitations refuses anyone who does not already hold a live grant here, since every
invitation lets a device into the synced folder and a name that was merely reserved is
not a member. The claim binds the machine's public key, so a name that is ever re-keyed
stops resolving to anybody. A machine cannot claim itself, an owner cannot be owned,
and the file being writable by anyone with the folder buys nothing: only a signature by
the identity being claimed *as* counts.
The machine finishes joining with `ferry team pending --as josh` on the machine holding
josh's key, or from the dashboard while signed in as josh. The agent loop reports the
wait rather than signing it, because it holds a machine's key and not a person's.
The `owner` field on an invitation is appended to the signed payload only when it is
set, so every invitation written before this release still verifies over exactly the
bytes it was signed over.
- **A machine or an agent can be killed from any machine you own.** `ferry team revoke
--name josh-beastly` used to be the master's command and nobody else's. Now the master
still ends anyone on the project, and everybody else ends their own machines and agents
- signed by the owner, or by any *other* machine of the same owner. That last one is
the case that matters: a kill switch you can only reach from the machine you are trying
to kill is not a kill switch. The laptop still on your desk ends the one that left in a
taxi, with no master involved and without your operator key having to be on the machine
doing it.
A sibling can end a sibling and nothing else - it cannot grant, cannot claim, cannot
speak for its owner anywhere. The worst a stolen laptop does with this is switch your
other laptops off, which you undo by claiming them again; the alternative was a stolen
laptop that kept working because you were not sitting at the right desk. It cannot
revoke itself, so it cannot cover its tracks. Re-claiming a machine clears a revocation
a sibling or the owner signed, and never one the master signed.
It is a signed tombstone at `owners/<agent>.revoked.json`, not a deleted file: on a
synced folder a delete wins on one machine and then loses an argument with the next
replica that still had the file. Entitlement is checked when the revocation is read,
not when it is written, so writing one by hand into the folder achieves nothing.
### Fixed
- **`ferry doctor` told a stale machine the fleet was level.** The `versions` check
listed machines behind *this* one and never said this one was behind, reasoning in its
own comment that the stale machine "is never the one you are sitting at". It is, and
the check reassured precisely the machine that needed telling: grouchly sat on 0.5.10
beside 0.5.11 being told nothing was wrong. It now reports both directions and leads
with *"this machine 0.5.10 is BEHIND the fleet's 0.5.11 - run 'ferry update' here"*,
because that is the half you can act on without leaving the chair.
- **Pinned public keys no longer share a name with private ones.** The trust-on-first-use
pin store wrote `agents-pinned/<name>.key`; the private key store next door is
`keys/<name>.key`. Same extension, and both files exactly 64 bytes, because a
hex-encoded 32-byte key is 64 characters whichever half it is. A careful reader with the
source open still took the pins for private keys. Pins are `<name>.pub` now. The old
name is read when the new one is absent and carried across, because a rename that
stopped finding the old pins would re-run trust-on-first-use against whatever the
channel says at that moment - the one moment a pin exists to distrust.
### Security
- **RUSTSEC-2026-0285 in `rustls`.** TLS 1.3 handshake messages were accepted across
encryption-level boundaries. 0.23.42 to 0.23.45, with `rustls-webpki` 0.103.13 to
0.103.15. A patch bump inside 0.23; nothing else in the lockfile moved.
## v0.5.11 - 2026-09-14
Beta. Sync that said it was healthy while nothing moved, and licences that verify
with nothing to phone home to.
### Fixed
- **`ferry enable` no longer breaks sync by moving a folder in silence.** Syncthing's
config POST replaces a folder carrying the same id, its path included, so registering
blind re-pointed a `<project>-ferryman` folder that already existed at another path
and said nothing about it. Worse, Syncthing writes the `.stfolder` marker when it
*creates* a folder root and never when it updates one, so the moved folder landed on a
directory with no marker and Syncthing then refused to touch it in either direction -
"folder marker missing" - forever, silently. Registration now reads the folder id
first: unchanged path, it only widens the share list; different path, it moves the
folder deliberately and reports where it came from (`moved_from`, printed as
`moved from <path> - that path no longer syncs`). This is why `ferry channel syncthing
off` then `on` was the only thing that fixed it: the delete made the next
registration a genuine creation.
- **Ferryman writes the Syncthing folder marker itself.** `.stfolder` is created
alongside the channel directory rather than left to Syncthing, so the missing-marker
failure cannot happen whichever way Syncthing treats the registration.
- **`ferry doctor` stops reporting a dead folder as ready.** The `syncthing` check only
asked whether the daemon answered and counted paired devices, which is why it said
healthy while nothing had synced in either direction. A new `syncthing_folder` check
reads `/rest/db/status` for this project's folder and fails on any state that is not
`idle`, `scanning` or `syncing`, quoting Syncthing's own error.
- **`ferry channel syncthing on` no longer unshares the folder it is repairing.** It
called `syncthing_register_folder(&route, &[])` - an empty share list - so the command
people reach for to fix a folder silently dropped every device it was reaching. It now
goes through `syncthing_share_folder`, which reads the current device list first and
adds to it. Found by causing it: repairing two folders on a live machine took them
from two devices to one, and nothing said so.
- **The test suite no longer rewrites the operator's real ferry manifest.** Tests run
`ferry enable` in temporary workspaces; `enable` found the machine's real ferry root
and filed the temporary project into it. Because filing merges by project id, a
scratch project sharing a name with a real one overwrote the real one's channel path
with a directory that was deleted seconds later - four live projects on one machine
went that way in an afternoon. `Root::adopt` now refuses to file a channel under the
machine's temporary directory into a root that is not itself temporary, and returns
whether it filed rather than reporting success either way. A temporary root filing its
own temporary channels is untouched, which is what every test fixture does, so no test
had to opt in and none can forget to.
- **`ferry root gather`** moves scattered channels into the root's `comms/`, one place
for every channel. `--dry-run` says what would move. Only the channel directory moves:
keys and config stay in the project, because keys must never enter the directory
Syncthing carries. Peers are unaffected - a Syncthing folder's path is local to each
machine and devices match on the folder id - and the share list is preserved.
- **A channel may live outside its project.** `validate` required `communications` to be
exactly `<attachment>/ferryman`, which made a shared comms root impossible. What that
rule was really protecting is now what it says: the channel may be anywhere outside the
workspace, and never anywhere inside it, because a channel inside the workspace would
put the work itself into the synced folder.
### Security
- **CVE-2026-48504 in `opentelemetry_sdk`.** `BaggagePropagator::extract_with_context`
parsed an inbound W3C baggage header before applying the size limits, so an oversized
header bought more CPU and allocation than it should have. Moderate, availability only,
and not reachable from anything Ferryman does - nothing here accepts inbound
propagation headers, the OTLP integration only exports - but the fix is free. The whole
OpenTelemetry set moves to 0.32 together, which `tracing-opentelemetry` 0.33 finally
allows; the partial bump Dependabot proposed does not compile, and the manifest says so
where the next person will look. Both lockfiles, the workspace's and the tray's.
- **`chacha20` 0.10.1 was yanked.** Moved to 0.10.2. It is the cipher under the sealed
licensor key, so it is not a dependency to leave on a version the registry has
withdrawn.
- **`reqwest` stays at 0.12 on purpose.** 0.13 builds, but its `rustls` feature swaps
`ring` for `aws-lc-rs`, and `aws-lc-sys` wants cmake and a C toolchain - a hard stop on
exactly the unattended machine an agent is asked to install this on. The OTLP exporter
pulls a 0.13 of its own regardless; one duplicate crate is cheaper than a native build
dependency.
### Added
- **Licences that verify with no server.** An entitlement is a small signed document:
subject, seat and device allowances, an issue date, an optional build cutoff, and an
ed25519 signature over the payload. The licensor's public key is compiled into the
binary; `ferry license status` reads the installed entitlement and verifies it locally.
Nothing phones home, and there is nothing to phone.
- **Expiry is measured against the build, not the clock.** `build.rs` stamps the git
committer date into the binary, and an entitlement that covers builds before a date
covers every binary built before it, forever. Setting the system clock back gains
nothing, because the clock is never consulted; a licence that stops at a date simply
stops covering newer builds, and the copy already installed keeps working.
- **`ferry license keygen` / `issue` / `install`.** The licensor key is sealed at rest
with PBKDF2-SHA256 at 600,000 iterations and XChaCha20-Poly1305, and the passphrase is
typed at the console - it is never an argument, never an environment variable, and
never written anywhere. `issue` takes `--to`, `--seats`, `--computers`, `--mobile`,
`--until`, `--note` and `--referred-by`; omitted allowances mean unlimited. Referrals
are appended to the signed payload only when present, so entitlements issued before
referrals existed still verify byte-for-byte.
- **A licence claim proves the machine holds the licence, not merely a copy of it.** The
claim is signed by the seed-derived operator key whose public half is the entitlement's
subject, so pasting someone else's licence into your channel proves nothing to anyone.
- **Seat counts read the licence.** `FleetCount::exceeded_under` uses the entitlement's
allowances when one is installed and falls back to the free tier when none is.
### Changed
- **`ferry enable` starts the managed Syncthing instead of noting that it is down.**
Enabling on a machine whose daemon was not running registered nothing and carried on,
which is a bad first five minutes for someone who just pointed an agent at the repo.
- **Syncthing installs from the project's own releases on Linux.** Rather than guessing
between apt, dnf, pacman and apk, the static binary for the machine's architecture is
downloaded and unpacked per-user under the machine state directory. Nothing is
elevated, and `find_binary` looks there.
- **Setup reads the git identity before it asks for one.** `git config --get user.email`
is consulted first, so an agent working unattended does not stop on a question the
machine can already answer.
## v0.5.10 - 2026-09-09
Beta. An invitation no longer names the person; the person names themselves.
### Changed
- **Generic invitations.** The name field on the Teammates tab is optional. An
invitation made without one carries no name at all; the joiner's Claude asks them
what name they want, checks it against the channel (`ferry team claim <name>` says
free, taken, reserved, or not synced yet), and claims it. Until then the joiner is
`USER` in the prompt and `guest-<invite id>` on the roster; on claim the placeholder
is renamed `<name>-<host>` and the roster entry for the guest is removed. A named
invitation still reserves that name for the person it was made for.
- **One invitation pairs one device.** When several devices knock with the same
handshake, `settle_pending` takes the most recent knock and leaves the others
pending, so a second machine cannot ride in on a code that was already used.
- **The master's key is on the channel.** Loading Teammates as the master publishes
the operator's own `agents/<name>.json` to the channel if it is missing there, so
teammates can verify the master's grants without a manual copy.
- **The joined device is named after the real hostname** (`hostname::get()`), not the
`COMPUTERNAME` variable, which sandboxed and non-Windows profiles do not set.
### Prompt
- The invitation prompt now tells the joiner which Claude can run it (Claude Code, or
Claude Desktop with a shell), that the project appears on their disk once the
inviter is online, and to ask for a name first and claim it before anything else.
## v0.5.9 - 2026-09-09
v0.5.8 built for Windows only: the managed-Syncthing supervisor detached its child with a
raw `setsid`, and `ferryman-ops` forbids unsafe code, so every Linux and macOS target
failed at build. This release is v0.5.8 with that one block replaced by std's
`process_group`, and the same tests, clippy and fmt run on Linux as well as Windows.
## v0.5.8 - 2026-09-09
Beta. Bringing a person onto a channel is one code and one prompt; the doctor tells
the truth about Syncthing; the master is a person.
From bringing one non-developer onto a channel and finding out what the tool made
them do by hand. The findings and their order are in `_launch/ONBOARDING_ABC_REVIEW.md`
(scratch); this is the first batch.
### Fixed
- **`ferry doctor` told the truth about the wrong Syncthing.** Ferryman can run its own
Syncthing (`%LOCALAPPDATA%\SyncthingFerry`), and nothing in the code knew that: the
config lookup read the person's own `Syncthing\config.xml`, the API base was
hard-coded to 8384, and "no key found" and "process not answering" both came back as
an empty peer list. Doctor said `ok syncthing reachable; 0 device(s) paired` against
a managed instance carrying four live devices, and said the same when it was not
running at all. Now the managed instance is looked for first, address and key always
come from the same `config.xml`, and each failure names itself.
### Added
- **`ferry syncthing start | stop | status`** supervises the managed instance: `start`
creates its home on first use and waits for the API; `stop` goes through Syncthing's
own shutdown and refuses to touch a Syncthing that is not Ferryman's; `status` shows
which config is read and every device with its live connected state. `ferry doctor
--fix` starts it when it is configured but down, and the worker loop does the same
between passes. Doctor reports paired, connected, and how many devices this folder
is shared with.
- **Secrets in one line on both ends.** `ferry channel secret get NAME --env` prints
`NAME=value`, so `>> .env` writes a line tools can read (`--key` to rename).
`ferry channel secret set NAME --from-env [KEY] [--env-file F]` reads the value out
of a `.env` line without it touching argv, a pipe, or a temporary file. Sealing to a
name that is reserved but has not joined yet says exactly that.
- **Invitations: one code from the master, one line for the newcomer.** `ferry team
invite create --name david --agent david-agent` (or Teammates -> *Make the code*)
reserves the names, writes a signed record, and prints a code plus the one line to
send. On the new machine `ferry team invite accept <code>` - or the `join.ps1` /
`join.sh` one-liner, which installs ferry first - starts the managed Syncthing
(installing it with winget or brew when absent), names the device after the invite,
trusts the inviter, enables the project under the right folder id shared with the
inviter's device, creates the operator, and writes a signed acceptance. The inviter's
ferry - the worker loop, or the dashboard while open - sees the device knocking under
the invite's name, trusts it and shares the folder; when the keys and acceptance sync
back, the dashboard signs the grant the invite promised. Nobody sees a device id.
- **The master is a person.** The dashboard offers *I am the master of this project*
whenever no master exists, signed with the session's already-unlocked operator key -
no password typed twice - and flips grants to required. When `ferry enable` creates
an operator on a machine whose agent was just declared master implicitly, the role is
transferred to the person, signed by the agent, so the chain reads "declared, then
disclaimed".
- **A first machine is the master.** `ferry enable` declares the master implicitly when
no declaration exists and either nobody else is on the roster or this machine is the
only orchestrator. `--master` stays for every other case. Doctor now reports the
master, or that there is none.
- **Inviting a second person makes grants required.** The dashboard's invite flips
`grants = "required"` in `bridge.toml` and records it in the ledger. Doctor warns when
grants are open and more than one operator is on the roster.
- **Version skew is visible.** Each machine's record in the channel carries the
Ferryman version it last ran (stamped by `ferry enable`, `ferry license register`,
and the worker loop on start). The Fleet page shows it with a *behind* badge, and
doctor lists machines older than this one with the `ferry update` line.
## v0.5.7 - 2026-09-02
Setup you double-click, an API spec that cannot drift, and the first release
signed with SSH.
### Added
- **Setup is a file you double-click.** `Ferryman-Setup.cmd` and
`Ferryman-Setup.command`, attached to every release. They install Ferryman, ask for
an email and a folder, enable the project and open the dashboard — after which it is
a web page. The install instructions were `curl … | sh` and `irm … | iex`, and both
are command lines, which is the one thing this tool tells its users they will not
need. A `.ps1` cannot even be double-clicked; Windows opens it in Notepad.
- **`ferry dashboard` opens the browser** rather than printing an address and hoping,
and `ferry enable` now ends by pointing at it instead of asking for two more
commands. `--no-open` for headless machines.
- **A person can decline a release**, signed, with a reason — and declining is never
disabled by red tests or staleness, because those refuse a release and must not
refuse a person's refusal.
- **Teammates can be invited, with per-project access levels.** Inviting reserves the
*name*: an operator key is sealed under that person's own password, so a key minted
here would be a key this machine has seen.
- **Engine cost is measured.** `Learning` now carries token counts read from the result
in whatever shape the engine reported (OpenAI's, Anthropic's, or flat). Optional
rather than zero — silence is not a free run.
- **`ferry channel submit --result-file`**, for the same reason `--task-file` exists.
It carries the largest payload in the channel and was the one command without it.
- **`scripts/uninstall.sh` and `.ps1`.** They keep your operator identity unless you
pass `--identity`, never touch channels, and support `--dry-run`.
- **The whole API is documented**, and a test fails if a route is added without a spec
entry. It described 11 of 31 endpoints.
### Fixed
- **A lost identity no longer reads as a first run.** The dashboard answered "does an
operator exist" from one machine's disk and offered a setup token — so an operator
whose sealed key had been deleted was told they had never set one up, while their
signatures were still verifying in the channel. It asks the channel now, names who is
missing, and defaults to recovery.
- **Recovering a published name needs no console token.** The phrase must derive that
exact published key, which only its owner can do — a stronger claim than a secret
printed in a terminal window the person may never have seen.
- **An old approval hid the button for approving the new one.** The release page matched
approvals by version alone, so an approval of one commit made it render as settled for
a different commit the gate was refusing. Page and gate now both come from `may_sign`.
- **Your operator key is kept in two places.** It lived only in `%LOCALAPPDATA%` (or
`~/.local/state`), where every disk cleaner points; one deleted the whole directory
overnight. A second sealed copy lives in `~/.ferryman/operators`, and signing in puts
the primary back if it has gone.
- **Two machines sharing a hostname got a frightening error.** The refusal named
impersonation first; it now names the ordinary cause — two VMs from one image — and
gives the fix.
- **Errors print a sentence, not a stack trace.** `FERRYMAN_BACKTRACE=1` brings it back
for whoever is fixing ferry rather than using it.
### Notes
Releases are signed with SSH from this version on. v0.5.6 and earlier are GPG-signed.
## v0.5.6 - 2026-09-01
One place to put things, an operator whose "stop" means stop, and a fleet
that keeps itself current without being asked.
### Added
- **One root called `ferry`.** `ferry/comms/`, `ferry/repos/`, `ferry/work/`
and a `.ferry` manifest that tells an engine where everything is. Finding
things used to be guesswork - the dashboard read the directory beside wherever
it happened to be launched, which finds a fleet kept as siblings, finds nothing
on another drive, and fails *silently* by showing one project as though one is
all there is. Nothing of yours is moved into the root: repositories are linked
and recorded, never relocated. See ADR 0019.
- **The manifest fills itself in by being used.** Every time a route is
resolved, that project and its repository are filed. There is no command to
run and nothing to keep in sync by hand; a fleet that has been running for
weeks acquires a complete manifest by carrying on working. Nineteen channels
filed themselves on the first pass here.
- **A project picker in the dashboard**, backed by discovery that reads the
manifest first, then the learned index, then the directory scan - so the answer
improves as the install is used rather than depending on where it was started.
- **Workers keep themselves current.** A long-running `ferry agent` checks for a
newer release at most every six hours, installs it, and hands over at the next
natural boundary. Ferryman is getting better daily and an install that needs a
person to notice is an install that falls behind.
- **A person can say no to a release.** The channel could only hold approvals, so
declining had nowhere to go and silence was indistinguishable from refusal - which
meant "did anybody look at this" could not be answered. `Decline` writes a signed
denial with a reason. It is a separate record rather than a field on the approval,
because adding a field would have changed the bytes every existing approval was signed
over and retroactively turned real consent into an unreadable signature. An unsigned
denial is ignored: a forged refusal blocking every release is a denial of service any
peer could mount, and refusing to ship is not the safe direction when the block itself
is unattributable. Declining is never disabled by red tests or staleness - those refuse
a release, and must not refuse a person's refusal.
- **Teammates can be invited, and what each may do is on the screen.** Inviting reserves
the *name*, and says so: an operator key is sealed under that person's own password, so
a key minted here would be a key this machine has seen, which is the thing operator
identities exist to prevent. Access levels are `MasterGrant`, which has carried
projects, roles and capabilities since ADR 0014 - authority that was in the channel and
on no screen, so who could do what was answerable only by reading JSON.
### Fixed
- **The release page could offer to approve what the gate would refuse.** `may_sign` had
one caller, `ferry release status`, at a terminal; the dashboard never consulted it. Two
answers to one question, with the reassuring one in front of the person. The page now
renders the verdict from the same function the signing path calls.
- **A killed order came back.** The worker acknowledged a signed `kill`, dropped
its claim and returned - correct for the running process, never made true of the
order. Next poll, the acknowledgement made the interrupt stop being pending, the
order read as plainly `Open`, and the same worker claimed it and ran the work the
operator had just stopped. Because `list_tasks` is a correct FIFO, a killed order
sat permanently at the *head* of the queue and re-ran itself ahead of everything
issued after it: one killed at 20:11 was acknowledged at 23:13, re-claimed at
23:45, and held a worker for thirty-one minutes while live work sat last in a
line it could never reach. Death now belongs to the order, is read from the
order's own signed files by every machine, and needs a valid signature to
declare. `Kill` and `Pause` also did byte-identical work; kill was only ever a
pause that sounded final. See ADR 0020.
- **Two projects could share one worktree, and one committed to the other.**
Moving worktrees into a shared `ferry/work/` keyed them on `(order id, agent)`,
which was unambiguous only while they lived beside their own repository. Order
ids are short human names nobody coordinates across projects, and a ferry root
exists to hold many projects - so the second task found a valid checkout sitting
there, reused it, and ran and committed in the wrong repository, reporting
success. Worktrees are now keyed on the repository as well, and reuse verifies
the directory is a checkout of *this* repo rather than merely a checkout.
- **The ledger recorded claims that never happened.** The entry was written the
instant the claim file was, before the re-read that decides whether the claim
held, so every lost race went into the tamper-evident chain as "claimed order X".
A machine losing the same race every ten seconds - which had been happening for
six hours - writes thousands of signed entries for claims it never had. The
ledger's whole value is recording what happened.
- **`Restart=on-failure` would have permanently killed a self-updating worker**,
because a worker that hands over cleanly after an update exits successfully.
- **The dashboard reported a valid approval as coming from an unknown signer**,
having verified it against the roster as it was at boot rather than as it is.
- **CI's test job had been red on all three platforms since before v0.5.4**, and
v0.5.4 and v0.5.5 were both tagged, signed and published over it. Four dashboard
tests each asked for their own machine state directory through a first-call-wins
`OnceLock`, so three of them silently got the first one's - already holding an
operator - and the test that needs a virgin machine passed or failed depending on
which test the scheduler started first. It won that race on the maintainer's
machine and lost it on every runner, which is how local and CI disagreed for weeks
with neither of them lying.
## v0.5.5 - 2026-08-31
The release that can see what people said, and the last one anybody has to
install by hand.
### Added
- **The dashboard can show the conversation.** It showed team, tasks, stats,
ledger, learnings, roster, fleet, memory, secrets and cost - and not one word
anyone had said, while `conversation.rs` had been storing signed turns in the
channel all along with the Telegram bridge as their only writer. Conversations
down the side, the thread in the middle, a box to type in. Typing here appends a
signed turn into the same file the bridge appends to, so what is said in the
browser and what is said in Telegram are indistinguishable afterwards and every
agent reads both. The dashboard is a view over the synced channel, never a
second channel: a message that existed only in the dashboard would be invisible
to the fleet and would die with the process.
- **`ferry update`, and installs that keep themselves current.** Fetches the
release for this platform, verifies it against the checksum published beside it,
and installs it where ferry runs from; `--check` says what would change and
installs nothing. `ferry agent` and `ferry dashboard` do it on the way in, at
most once every six hours, because a notice somebody has to act on is how an
install ends up four minor versions behind. It only ever replaces the binary on
disk - the running process keeps the code it has and every worker in flight
finishes its task, so the new version takes effect at the next start.
`FERRYMAN_NO_AUTO_UPDATE=1` turns it off.
### Fixed
- **A worker that died before its first heartbeat held its task forever.** The
staleness test read only the heartbeat, so a claim carrying none had nothing to
compare against and stayed `Claimed` indefinitely - the one shape of death ADR
0011's recovery story could not see. The claim time is the fallback now.
- **`el.hidden` did nothing to labels on the sign-in form**, because
`#login label{display:block}` overrode the user agent's `[hidden]` rule: the
create-identity form asked for a recovery phrase above a field that was not
there, on the first screen a stranger sees.
- **`ferry channel review --notes-file`**, for the same reason `--task-file`
exists: a shell splits a multi-line verdict on an apostrophe, and a review is a
signed ledger record, so a mangled one is worse than a missing one.
## v0.5.4 - 2026-08-31
The release that makes a person someone before it makes them configure anything,
and gives the orchestrator a memory that outlives the machine holding it.
### Added
- **Marvin: the orchestrator is a memory, not a machine** (ADR 0017). What has to
survive when an orchestrator stops is not a machine and not a model - it is what
it knew. `ferry marvin brief` records the objective, what is in flight and why,
the standing constraints, the decisions that never became ADRs, what was tried
and rejected, and what is waiting on the human; `ferry marvin resume` prints it
back in the order a successor needs it. Written continuously rather than at
handoff, for the same reason `ferry-deadman` exists: running out of context is
never a graceful event, so the handoff cannot be an event either, and `brief`
therefore touches only the sections it is given. Exactly one machine holds Marvin
at a time - `take` refuses while the current holder is still being heard from and
says how long ago that was, `release` hands it straight over, and writing to the
memory is itself the heartbeat. Each holder writes its own file, so
one-writer-per-path holds; those files are pages of one memory and `resume` reads
them as one. Work in flight is read from the channel rather than from the memory,
so a stale page cannot hide a task.
- **`ferry channel order --task-file`.** An order worth issuing is worth writing in
an editor. A shell splits a multi-line brief on an apostrophe and the order lands
signed and mangled, which is worse than a missing one because it looks like it
worked.
- **One seed, and every identity derives from it** (ADR 0016), in the channel
crate. A machine may hold an `operator.seed` beside its other machine state -
32 bytes, owner-only, never in a project directory and never in the channel -
and an identity being created for the first time is derived from it rather than
minted at random: `HKDF-SHA256(seed, "ferryman/v1/sign/" || name)` for signing
and `"ferryman/v1/encrypt/" || name` for sealed secrets, over the case-folded
name so `Fang` and `fang` cannot become two identities again. Distinct keys per
agent, so "which agent did what" survives. The derived key is then written to
the keystore and the keystore wins from that point on, which is what keeps
rotation possible: an agent that must re-key writes a new key and the roster
reports `KeyChanged` exactly as it does today. Nothing that already has a key
is re-keyed, anywhere, and a machine with no seed behaves precisely as before.
This also makes true the sentence ADR 0015 wrongly claimed was already true
about the encryption key. The first-run flow and the recovery phrase are a
separate change; this one is the crypto underneath them.
- **The first thirty seconds: an identity and a recovery phrase.** `ferry enable`
at a terminal now creates the operator seed on first run, shows it once as a
BIP-39 English recovery phrase (24 words), and prints one operator fingerprint
to verify out of band - one value per person instead of one per agent per
project. An existing seed is used silently and never re-displayed. `ferry
identity show` prints that fingerprint and which agent identities on the
machine derive from the seed; `ferry identity recover` restores the seed from
the phrase onto a new machine, and with `--force` onto one that already holds a
seed, moving the old seed aside rather than deleting it. The seed bytes and the
phrase never reach a log line, a result payload, or the channel, and
`*.seed`/`operator.seed` are excluded from the channel's `.stignore`.
- **The dashboard operator is the seed, and the password is the local unlock.**
The dashboard's operator identity no longer mints a random key: its signing key
is the third derivation from the machine seed, `HKDF-SHA256(seed,
"ferryman/v1/operator/" || name)` - bound to the operator's name, exactly as an
agent's key is bound to the agent's, so two operators on one machine are two
keys and not one - and the recovery phrase genuinely restores the person, not
just the agents. The bare `"ferryman/v1/operator"` remains what it always was:
the one machine fingerprint per seed, which is not anybody's signing key. The password is demoted to the local unlock - it still
seals the derived key at rest and is what a person types to sign in, but it is
no longer the root of anything. Operators that predate the seed keep their keys
forever. The first-run experience now lives in the browser: opening the
dashboard with no operator creates the identity, shows the recovery phrase once
(with a three-word confirmation before the page moves on), and drops the person
into the product; recovery pastes the 24 words on a new machine; and a new
Identity page shows the one fingerprint, readable aloud. The one-time setup
token for the first operator is unchanged, for the reason given in the previous
entry: the bootstrap endpoint had no authentication once, and the token is the
proof of console access that no browser hand-off can reproduce without making
the secret available over HTTP. Recovery in the browser carries the same gate as
creation - an existing operator's session, or that one-time token - because a
recovery phrase is not a credential when the machine has no seed to check it
against: the caller simply supplies one.
- **`ferry-deadman`, a sub-product, at `crates/ferry-deadman`.** Timelocked
succession for any git repository: seal an archive to a future drand beacon
round, and it cannot be opened early by anyone, including whoever sealed it.
Useful with no Ferryman anywhere - Ferryman's part is only transport, and a
channel can carry the sealed artifact to a successor as ciphertext. In this
workspace so it is compiled, linted and tested with everything else rather than
living in a directory nothing builds, which found two Windows defects in the
first hour. Its six commits came in with their history, and it was relicensed
from MIT to the Ferryman Source-Available License on the way in.
## 0.5.3 - 2026-08-25
### Added
- **Secrets travel the channel, sealed** (ADR 0010). A credential is encrypted
to each recipient with X25519 + XChaCha20-Poly1305, keyed through HKDF-SHA256
salted with the ephemeral and recipient public keys, and only the ciphertext
is ever written anywhere. The encryption identity is a separate keypair from
the Ed25519 signing key, so sealing and signing cannot be confused for one
another. A machine's access hangs off its owner's: revoking a person revokes
their machines in the same act. Revocation is not retroactive, and the ADR
says so rather than implying otherwise.
- **Telegram is a first-class order surface** (ADR 0008), with the conversation
kept in the channel rather than beside the bridge, so an orchestrator reading
an order can see what was already asked and answered instead of asking again.
- **Roles are conferred, not claimed** (ADR 0014).
### Fixed
- **A dead worker was only detectably dead on Linux.** `process_alive` read
`/proc/{pid}` there and returned `true` everywhere else, so on macOS and
Windows a lock left behind by a worker that died read as a live worker
forever: the takeover in ADR 0011 never fired, and `retire` refused to release
a worker that was already gone. A fleet that can recover on one platform and
not the others is the failure ADR 0011 exists to remove. sysinfo answers for
the one pid off Linux; no new dependency.
- **Windows OpenCode workers silently ran Claude Code's arguments.** The `.exe`
suffix was stripped before the name was folded to lower case, so
`OpenCode.EXE` missed the engine table and fell through to `-p {prompt}` -
precisely the failure that table was added to prevent.
### Changed
- **The toolchain is pinned at 1.97** across the pin file, all five CI jobs and
the container build stage. sysinfo 0.39 requires 1.95, and a dependency bump
must never be the thing that moves the compiler under a project.
- **The crypto dependencies move a major version**: ed25519-dalek 2.2 to 3.0,
chacha20poly1305 0.10 to 0.11, sha2 0.10 to 0.11, rand 0.9 to 0.10. Existing
signatures verify unchanged - checked against a live channel, every record
written by 2.2, all of them Valid under 3.0, none Unsigned, Invalid,
UnknownSigner or KeyChanged.
- **CI builds the tray on every push.** It is excluded from the workspace,
correctly - it needs GTK, and agent machines are headless - which left it
compiled by nothing between releases. Its lockfile had already drifted behind
the crypto bump it depends on by path, and would have broken on this tag.
## 0.5.2 - 2026-08-25
### Fixed
- A build from the v0.5.1 tag stamped itself `-dirty`, every time, forever. The
manifest at that tag said 0.5.1 and the lockfile still said 0.5.0 for the six
workspace crates, so cargo rewrote those lines before `build.rs` asked git
whether the tree was clean - and `--locked` refused outright. The lockfile is
part of the version bump now rather than a commit that follows it.
## 0.5.1 - 2026-08-24
### Fixed
- A task's worktree starts from the default branch rather than from wherever
the checkout was left. 0.5.0 was tagged one commit before this landed, so the
release assets carried the bug while a build from main did not - the two
documented install paths disagreed about whether it existed.
### Changed
- The signing key's public half ships in `keys/estejosh.asc` with its
fingerprint in the release process, so a tag can be verified without a third
party being able to serve the key. 0.5.0 was signed and unverifiable
everywhere, which reads as checked from a distance and is worse than
unsigned.
### Added - dashboard team model and invitation prototype (`codex/novice-dashboard-ux`)
- **Dashboard redesigned around human teams and agents.** A new home and
install experience separates human teammates from AI agents throughout,
backed by a real `GET /api/team` endpoint that reads the roster and master
declaration and never invents ownership. Install and access controls are an
honest policy preview: without enforcement behind them they save drafts and
say so rather than claiming authority changed.
- **Teammate invitation onboarding, prototyped.** Owner-side invite composition
and a recipient flow, both non-mutating pending signed invitation
enforcement. The product model and remaining backend contract are in
`docs/DASHBOARD_TEAM_ACCESS_MODEL.md` and
`docs/TEAM_INVITATION_ONBOARDING_PROPOSAL.md`; design QA is filed under
`docs/reviews/`.
- Demo state is client-side only (`?demo=team`) and never touches live APIs.
## 0.5.0 - 2026-08-24
### Added - grants as leases: the lifetime primitive behind the team model
- **`ferry channel lease grant | renew | revoke | list`.** Access grants are
now lease-shaped, per ADR 0013: a short-lived signed lease naming one
subject, scopes, and optionally one resource (a secret id, a repository).
Renewal extends from now by the original issuer only; a visible revocation
ends authority immediately where seen; expiry ends it everywhere, including
machines that never sync again. Every issue, renewal, and revocation lands
in the audit ledger. Existing worker leases are untouched - tokens without
grant fields sign under exactly their original payload bytes.
- **ADR 0013** records the semantics and what is deliberately left to policy:
who may be trusted as an issuer is the enforcement layer's decision, not the
primitive's.
### Added - the loop and the bill become observable
- **`ferry agent status`.** The one command for "why is nothing happening":
whether the worker process is alive, which task it holds with heartbeat age,
and the exact claim-gate decision the poll acts on - paused, outside working
hours, someone typing, memory floor - naming the `agent.toml` setting that
causes it. `--json` for callers.
- **Recorded engine usage makes cost real where engines report it.** Workers
now parse the token counts an engine prints (Claude Code's JSON result, JSONL
streams restating cumulative totals), record them in the trajectory and the
signed result, and `ferry cost project` bills from recorded numbers instead
of reading structurally zero. Engines that print nothing stay an honest zero.
- **ADR 0013: agent access grants are renewable leases.** The semantics behind
the dashboard team-access and secret-transport proposals: authority is a
short-lived signed lease renewed by its owner, so revocation means "stop
renewing" and an offline machine expires out of authority at a known horizon
instead of holding a durable capability until a revocation file arrives.
THREAT_MODEL states the same rule.
### Added - the setup knows before the first task does
- **`ferry doctor`.** One read-only command that answers "will this machine
actually run a task?" before one fails trying: channel discovered, `agent.toml`
parses, the engine resolves on PATH, signing key and roster entry exist,
Syncthing reachable, credentials file present (never its contents). Every
failing check states its remedy. `--json` for a calling program; exit 1 when
not ready.
- **Engine-aware worker args.** `ferry enable --command opencode` now writes
OpenCode's real non-interactive contract (`run --auto {prompt}`) instead of
Claude Code's `-p`, which failed on every task for every OpenCode operator;
`codex` gets `exec --full-auto` as its config already documented. Claude and
unknown engines keep the historical args, and Claude's permission grant is
still yours to add — never written in uninvited.
- **Enable reports engine presence.** Human output warns when the configured
engine is missing from PATH; `--json` gains `agent_args` and `command_found`,
so an agent caller can react without parsing prose.
### Fixed
- `settle_worktree` tripped `clippy::too_many_arguments` under
`-D warnings` with the pinned toolchain, failing `cargo clippy --workspace`
locally on an unchanged tree. Allowed explicitly, with the reasoning inline.
## 0.4.1
A fleet is a mixed fleet. Everything here was found by running 0.4.0 across Windows,
WSL and Linux at the same time, and none of it is visible on any single one of them.
**If you run Ferryman on more than one machine, upgrade.** The identity faults below
are silent on a case-folding filesystem and split an agent in two on a case-sensitive
one, which is exactly the pair most people have.
### Fixed - one name, one identity
- **`Grouchly` and `grouchly` were two agents with two keys.** An agent name is a
filename in three stores at once: the roster entry in the synced folder, the pinned
key store, and the private key store. Whether two spellings are the same file
therefore depended on the filesystem - NTFS and APFS fold case, ext4 does not. So the
same two commands produced one agent on Windows and two on Linux, each with its own
key; messages addressed to one were invisible to the other, and a message signed by
one read as `UnknownSigner` to a machine that knew the other. Names are now folded
where they are minted, published, and put into messages.
Existing keys are **adopted, never rotated**. A machine holding `keys/Grouchly.key`
and nothing else would otherwise find no key under the folded name and mint a fresh
one - publishing a second key under a name the fleet already trusts, which every
other machine would correctly read as an impostor.
Rosters are folded on **read**, not rewritten: the synced folder is
one-writer-per-path, and someone else's roster entry is not yours to delete.
Signature checks match case-insensitively so a message already in flight, signed
under the old spelling, still verifies - otherwise upgrading would itself raise a
fleet-wide impersonation alarm.
- **A Syncthing conflict copy of a roster entry was read as a second agent.** Found by
grouchly. `agents/beastly.sync-conflict-....json` carries the same `name`, so the
roster held two `beastly` entries - and the conflict copy is usually the older,
*keyless* one, so it could displace a real published key. This, not the
capitalisation, is what produced "registered participant names must be unique" on a
live channel.
### Fixed - signing
- **A message could be published unsigned in a person's name.** Every signing site was
written `if let Ok(identity) = signing_identity(..)`, which computes the refusal and
discards it. The reasoning was sound - a fleet that has not adopted signing must keep
working - but it covers one case and was applied to two. Where nobody has a published
key, unsigned is all there is and readers see it as unsigned. Where the roster
*knows* this sender and carries a key for them, "unsigned from op" is a claim about
who spoke, made to readers who could have checked it, with nothing behind it. That
case now refuses. Five sites: send, order (twice), claim, review.
### Added - operators are people
- **`ferry operator create|export|import|list`.** A human operator's key is sealed
under their password rather than kept in plaintext like a machine key, which is what
makes it safe to carry: the sealed record can cross machines and is useless without
the password. So one person is one identity everywhere they work, instead of a
separate key per machine under the same name - which the roster's first-key-wins
correctly rejects. `import` verifies the record against the roster **before**
installing it, so a mismatch is caught at import rather than at the first rejected
approval.
- **Operator identities are machine-wide, and a machine can hold several.** Being the
operator of nineteen projects used to mean nineteen imports. Machine-wide because a
person is not per-directory - the same reasoning that moved machine keys once per
machine. Several per machine because these records are *sealed*: two people can keep
an identity on one workstation without being able to sign as one another. A
project-local record still wins for that name, so one project can have a different
operator from the rest of the machine - use `--this-project-only`.
- **Operators can receive messages.** A created operator published an empty capability
list. Nothing refused; every path that routes by capability simply skipped the human.
### Fixed - platforms and CI
- **A scorer that ignores its input was sometimes recorded as never having run.** A
scorer that exits without reading stdin (`exit 0`, `test -f build/report.json`, a
`grep -q` matching the first line) makes `write_all` return EPIPE. That was read as
"could not run", which abstains from the fleet's synced learning record - so a real
verdict was silently discarded whenever the race went the wrong way. It passed three
runs in four.
- **`ferry enable` left the signing key one `git add -A` from being committed.**
- **macOS is built and tested on every push, not only on tags.** It was gated to tags,
and the cost was paid in full at the first release: 101 commits had never been
compiled on macOS, and the tag build was where we found out. Its failures now name
the failing test in the job summary, readable without a token.
## 0.4.0
The release this project was reviewed for rather than written into. Most of what
follows is a fix, and the detail is deliberate: for a tool that asks you to let models
work unsupervised on your machines, *how the author looks for problems* is more useful
information than a feature list.
**If you are running 0.3.x, upgrade.** Several of these lose work or forge identities.
### Fixed — work and identity
- **A worker could kill its own running task, in a loop it could not leave.** The loop
sampled free memory every five seconds and killed the agent CLI below
`min_free_ram_mb` — the same threshold that had just allowed the claim, while the
agent CLI is the thing consuming the memory. Then `bail!` became a retry, forever,
with the claim never released and no other machine able to take it. It also read
*system* memory, so a browser could kill your run. Removed. The pre-claim gate was
always the correct place, and `governor`'s own tests assert the promise this broke:
*"anything already running is unaffected."*
- **Any peer could permanently destroy a project's root of trust.** `ferry channel
master transfer` resolved the *current* master's identity with `load_or_create`,
minting a key under their name. Reproduced end to end: a peer holding only the synced
folder took the master role, after which every machine failed `master status`,
`lease` and `grants` with "signature does not verify", with no way back. Fourteen
call sites took a name from argv and would forge a key for it; all now refuse through
one helper. `ferry channel join` remains the only command that may create a key.
`transfer_master` additionally verifies the offered *key*, not the name.
- **Interrupts were bound to nothing.** A signed `kill` copied into another task's
directory — no key needed, just `cp` — made every worker abandon that task's claim.
Now bound to its order id and its filename's issuer.
- **One malformed file stopped a worker permanently, with no signature required.** The
interrupt reader parsed *above* the signature check and propagated, so a single
non-JSON file — or a Syncthing conflict copy — failed every pass every ten seconds,
forever. It now skips, as the signature check three lines below always did.
- **The audit ledger reported itself tampered after ordinary two-machine use.** One
`ledger.jsonl` in the synced folder, appended by every machine, guarded by a
machine-local lock. A sync conflict dropped records and broke the hash chain
permanently, because the file is append-only. Now `ledger.<agent>.jsonl` — one writer
per path, like every other artifact — verified per file and merged on read. Existing
ledgers are still read, so no history is lost.
### Fixed — secrets and prompts
- **`--sandbox` put credentials on the process list.** The container runner passed
`--env KEY=VALUE` into podman's argv, and `/proc/<pid>/cmdline` is world-readable.
Now the name only, with the value in the child's environment.
- **Operator key files were world-readable.** Salt, nonce, iteration count and sealed
seed at default umask. Now `0600`, and `0700` on the directory, via the same helper
the signing key has always used.
- **The dashboard answered anonymous callers.** Authentication was per-handler and
three of seventeen routes had it; the rest served order payloads, worker output, the
memory bank, the ledger, and every device's registered email. Now one layer over the
whole router with an explicit list of public paths.
- **Creating a dashboard operator required no credential at all** — anyone reaching the
port could mint an identity the whole fleet trusts. The first operator now needs a
single-use token printed to the terminal; every later one needs a session.
- **The dashboard's DNS-rebinding guard was a string prefix**, so `127.0.0.1.evil.com`
passed it. Now parsed as an IP address.
- **Memory-bank profiles were unsigned prompt text from a synced folder**, injected at
the front of every prompt framed as the agent's own trusted memory. Now signed and
verified three ways — content hash, signer identity, roster membership — and reframed
as a record rather than an instruction. Peer profiles are attributed, not asserted.
- **`/api/memory/suggest` invented its author** when unauthenticated, writing the byline
`"operator"` into the synced memory bank every agent reads.
### Fixed — Windows
Every one of these was found by running the binary on a real Windows machine. All of
them passed CI and both test suites first.
- **`ferry` crashed instantly**: `thread 'main' has overflowed its stack`. `main` was
one async fn holding every subcommand's locals, and Windows gives the main thread 1 MB
where Linux gives 8. Even `--version` died. Now the CLI runs on a thread with a stack
size we choose.
- **Shell task sources never worked**, because `sh` was hardcoded. And fixing that alone
was not enough: Rust escapes `"` as `\"` on Windows, which `cmd.exe` does not
understand, so quoted commands silently produced mangled output rather than an error.
- **`clippy -D warnings` failed**, which is a CI job on `windows-latest`.
- **The benchmark scorer recorded fabricated failures into the fleet's synced confidence
data**, because a scorer that could not spawn was indistinguishable from one that
failed. There are now three outcomes, and "could not run" abstains.
### Added
- **`ferry soak`** — a report you can paste into an issue: counts, category labels and
the build string. Redaction is structural rather than filtered: every field is a type
that cannot hold a path, a prompt or a secret, and run-log lines are reduced to a
label from a fixed vocabulary before counting. Prints by default; sends only with
`FERRYMAN_SOAK_URL` *and* `--send`, per invocation. Documented in `PRIVACY.md` and
pinned by a test that fails if the payload changes without the page changing too.
- **`ferry --version` now reports the commit** — `0.4.0 (53d577aa)`, with `-dirty` when
the tree was not clean. The previous release reported the same version before and
after a day of changes, which meant a fleet operator could not answer "did that
machine get the new build?" It came from an outside upgrade report, and it was the
sharpest thing in it.
- **`preamble_file`** — standing context placed byte-identically at the front of every
prompt, so provider prefix caching applies. A configured-but-unreadable preamble stops
the agent starting rather than quietly degrading.
- **`claim_window`** — hours during which a machine picks work up, local time unless you
append `UTC`. For cheaper overnight power, metered connections, or a desktop in a room
where someone is asleep.
### Changed
- `--read-only` dashboards now permit sign-in. With reads requiring a session, refusing
the only way to obtain one made the flag mean "unusable" rather than "cannot write".
- `PRIVACY.md` documented `checkin = "off"` in `agent.toml`; no code ever read that key.
Corrected, and the OpenTelemetry exporter is now documented — including that its spans
*do* carry agent names, project names and workspace paths, unlike anything else.
- The README gained a **Known issues** section. Ten of them.
### Known issues
Listed in the README rather than discovered by you. Notably: `ferry cost project`
reports `$0.00` because nothing records per-run token counts yet; engine prices and
quality priors are hand-typed constants; `ferry ask` attributes sources it has not
verified; an addressed order reports as `claimed` before anyone picks it up; the MCP
client has no timeouts; and the SBOM omits the tray binary.
## 0.3.1
- Syncthing wiring in the released binary.
## 0.1.0-preview
- Local single-node SQLite orchestration reference implementation.
- Project-local private Git workspaces, durable job state, worker leases, approval
gates, artifacts, SSE, agents, and bridge-owned project memory.