The wire contract for crew federation: several machines each running a full Collie, one of them
holding the phone-facing front door. Sibling to HERDR_API.md, which documents the
contract below Collie (the Herdr socket); this documents the contract between Collies.
Provenance convention, mirroring HERDR_API.md:
- Verified — read first-hand out of this repo at the cited
file:line. Existing behaviour. - Specified — normative for v1. The protocol is implemented on the v1 line (
bridge/crew/,cli/), so a requirement below is generally probeable — but only the spots carrying a Verified marker have been read back against the code. Nothing here records a full document-wide verification pass.
Unmarked prose is Specified. Where a rule extends existing behaviour, the existing behaviour is cited so a reviewer can check the extension is faithful.
1.8.0 renames every name a machine reads on this link. Nothing else changes. No route was added or dropped, no body shape moved, no timeout moved, and no rule below was rewritten. 1.7.0 renamed the word a person reads and kept the machine names (ADR 0038); this version finishes the rename (ADR 0039).
| What | Version 1 | Version 2 |
|---|---|---|
| Path prefix | /pack/v1/ |
/crew/v1/ |
| Headers | X-Pack-*, x-pack-* |
X-Crew-*, x-crew-* |
| Version constant | PACK_PROTOCOL_VERSION = 1 |
CREW_PROTOCOL_VERSION = 2 |
| Error codes and JSON field names | say pack | say crew |
| Signing context strings | collie-pack-warrant-v1, collie-pack-dial-v1 |
collie-crew-warrant-v2, collie-crew-dial-v2 |
Three readings keep the body below correct as it stands:
- The
v1in the path is a path segment, and it does not move. The protocol version travels in theprotocolfield ofhelloand inX-Crew-Protocol, never in the path. - Where the body prints the protocol version integer
1, read2on a 1.8.0 build. §7's window is exact in the same way it always was: a version talks only to its own version. The shape of that rule is unchanged, and only the integer moved. - Where the body says "v1" for the design line, it means the line this document has described since 1.0, not the version integer.
A build speaks version 2 and nothing else. §7's window is exact: a version talks only to its own version, so there is no negotiation and nothing in between two machines.
1.8.0 is the oldest build that speaks version 2. That is where the number first shipped, and it is
a named constant on the operator's side too: PROTOCOL_FLOOR_VERSION in cli/update-check.ts, tied to
CREW_PROTOCOL_VERSION by a test so the two move together
(ADR 0045).
A member below the floor lands on §7's exact-match window, and it is told twice. On the link, its
answers carry no crew protocol header, so the headerless ladder calls it unreachable for the first
minute and incompatible after that, with a reason that states the version this build speaks. On the
operator's own path, the lead's collie update --check walk reds the version check, naming both
versions and the remedy, which is to level that machine from its own terminal. The red blocks the crew
update rather than starting a roll that cannot finish (§7.1, ADR 0045).
The overlap that carried the 1.7.0 roll was removed in 1.9.0. 1.8.0 answered the old path prefix as
well as /crew/v1/*, fell back to it once as a member, read a crew id under either spelling, read two
COLLIE_PACK_* budget keys, renamed three state files on its first start and redirected the old census
route. All of it is gone. That was the plan ADR 0039 recorded when the rename shipped, and
bridge/removal-schedule.test.ts is now the tombstone that keeps those names out.
A state directory that never saw 1.8.x is named, never adopted. The one-time state-file rename went
with the overlap, so a collie that finds 1.7.0's pack-trust.json without crew-trust.json says so at
start, prints both hand edits, and stays solo. The canonical signed strings did not move, so a warrant
any 1.8.x lead minted still verifies (§18).
The rename table in §0 stays as the history of what moved. Everything below this section describes version 2 and only version 2.
| Term | Meaning |
|---|---|
| collie | One Collie instance — one server process on one machine. Every crew member runs a full one. |
| lead | The one collie that holds the managed front door. The phone talks to the lead and to nothing else. |
| peer | A collie with no front door, reached only by the lead over an authenticated crew link. |
| crew | The lead plus its enrolled peers. One lead per crew, always. |
| member | A collie enrolled in a crew — lead or peer. Identified by a member id. |
| solo | A crew of one: a lead with zero enrolled peers. Today's Collie, exactly. |
| deputy | The one peer the lead has named as eligible to take over. A deputy is a peer in every other respect. At most one exists in a crew at any instant (§18). |
| warrant | The short, lead-signed object that names the deputy: crew id, generation, deputy member id, deputy certificate fingerprint, issue and refresh times (§18). A standing permission, never a command. |
These are the shipped words. Earlier drafts said alpha for lead and bridge for collie; both are dead. The vocabulary decision and the rename rules for operator-visible surfaces are ADR 0012.
phone ──HTTPS──▶ lead ── /api/* (unchanged, today's handlers)
│
├── pinned mTLS + crew secret ──▶ peer A /crew/v1/*
└── pinned mTLS + crew secret ──▶ peer B /crew/v1/*
The same shape with the operator's own path drawn in, because it is the one people get wrong — code never rides the crew link:
graph TD
phone["phone (PWA)"] -->|"HTTPS /api/*"| lead
lead["lead collie (managed front door)"] -->|"/crew/v1/* : pinned mTLS + crew secret"| peerA["peer A collie (no front door)"]
lead -->|"/crew/v1/*"| peerB["peer B collie (no front door)"]
lead --- leadH["Herdr + agents, lead-local"]
peerA --- peerAH["Herdr + agents, A-local"]
peerB --- peerBH["Herdr + agents, B-local"]
op["operator"] -.->|"ssh"| lead
op -.->|"ssh"| peerA
op -.->|"ssh: code rides here, never the crew link"| peerB
The lead is the only dialler: a peer never calls the lead except at join, leave and promote
(§8.6), and publishes nothing for the phone to reach (ADR 0013).
The dotted edges are collie crew add / collie crew update, which push a git bundle over the
operator's own ssh (ADR 0016); the crew link carries
runtime data and never software.
Three rules generate most of this document:
- The lead consumes a peer's Collie HTTP API. The lead never dials a peer's Herdr socket, and no Herdr method name ever crosses a crew link. This is the mux-driver seam — a peer fronting something other than Herdr is invisible to this protocol. Argument: ADR 0011.
- A peer publishes nothing. No
tailscale serve, nevertailscale funnel, no PWA, no browser gates. A peer's crew listener is not a front door. - Solo pays zero tax. With zero peers enrolled, every observable byte is what it is today (§11).
The cheaper design is obvious and will be re-proposed: let the lead dial a remote Herdr socket over a forwarded connection and skip the second Collie entirely. It does not work, for reasons that are structural rather than aesthetic:
- The journal is host-local by rule.
bridge/journal/reads the agent's own session log off the disk it was written to, and every path goes throughcontainedRealpath()(bridge/journal/files.ts:39). A forwarded socket moves the terminal and strands the transcript. - Uploads are host-local by necessity.
uploadPane()writes into<stateDir>/uploadsand returns the absolute local path to be typed at the agent (bridge/server.ts:1075-1090). Herdr on that machine must be able to open it. A file on the lead's disk is useless to an agent on the peer. - Audit is host-local by design.
<stateDir>/audit.logis the record of what happened on these terminals (bridge/audit.ts:64-67). A forwarded socket writes every peer's history into the lead's log and leaves the peer's own operator with nothing. - It welds Collie to Herdr. The wire format would become Herdr method names, and the seam in rule 1 would not exist.
A collie runs in exactly one mode, decided by its enrollment state, not by a flag the operator maintains by hand.
| Mode | Front door | Serves the PWA | Browser gates (checkAccess) |
Crew listener |
|---|---|---|---|---|
| solo (lead, 0 peers) | yes | yes | yes | none — nothing is opened |
| lead (≥1 peer) | yes | yes | yes | none inbound; dials outbound |
| peer | none | no | n/a (no browser reaches it) | /crew/v1/* only |
In peer mode the browser-facing surface is disabled: serveStatic() (the SPA fallback,
bridge/server.ts:386), the /auth reserved placeholder (:383, isReservedAuthPath() :1347) and
the /api/* routes are not served to the crew listener. A peer answers /crew/v1/* and nothing else.
There is no second port. The crew surface is a path prefix on the collie's existing listener, with its own admission path.
The bind is COLLIE_HOST, and the operator owns it (amended 2026-08-08 — see below). The crew
listener answers on the one address COLLIE_HOST names (bridge/config.ts host, default
127.0.0.1); Bun.serve takes a single hostname, so there is exactly one bind, not a pair.
join does not touch it — reachability is the operator's to own here exactly as it is at §8.2 and
everywhere else in this design. The bind bounds only which interface the listener answers on; it
is pinned mutual TLS + the crew-secret admission that actually gates every request (§8.1). A
wider bind therefore widens who can attempt the gate, never who passes it.
Concretely: a peer reachable only over an overlay or a LAN must set COLLIE_HOST to that interface
— a loopback-only bind refuses the lead's dial. COLLIE_HOST=0.0.0.0 (or ::, or empty) binds all
interfaces; that is not a hole — the two factors still gate — but it is worth stating, so a peer on a
wildcard bind emits a loud one-line startup warning naming the effective bind, and collie crew status shows the resolved bind so an operator can see it rather than infer it. Collie warns, it
does not refuse to start (ADR 0013's posture: a startup refusal it cannot justify is paternalism).
Amendment (2026-08-08, F3). Earlier drafts of this section claimed a peer "binds loopback plus exactly the address the operator supplied at
jointime — nothing wildcard, no0.0.0.0". That dual-bind was never implemented and is not expressible (Bun.servetakes onehostname); the real bind isCOLLIE_HOSTalone, and nothing warned on a wildcard. The claim overstated a control — the exact failure ADR 0013 names — so it is corrected here to match the code, and the guardrail its intent wanted (the wildcard-bind warning + thecrew statusbind line) is now built.
The posture argument (front door vs. crew listener, and what an off-loopback bind costs) is ADR 0013, which amends ADR 0001 to one managed front door per crew; this document takes its conclusion as given.
Verified today: session selection is one line. The bridge reads ?session=<name> off the URL
(bridge/server.ts:172) and resolves it through the registry — absent or blank selects the primary,
unknown returns undefined and the caller 404s (bridge/sessions.ts:154-157). In the browser the
same value travels as the short ?s= (web/src/lib/session.ts:10,28-31) and withSession() renames
it to session= on the wire (web/src/lib/api.ts:70-77).
Specified: a pane is addressed by the triple (host, session, paneId).
- Browser URL:
?h=<member-id>, alongside the existing?s=<session>. - Wire (phone → lead):
host=<member-id>, alongsidesession=<name>, exactly as?s=→session=. - Absent or blank
hmeans the lead itself. This is the whole backward-compatibility story: every URL, every bookmark, every link and every cache key that exists today keeps resolving unchanged, and a solo instance never emits the parameter (mirroringsessionSearch()returning""for the primary session,web/src/lib/session.ts:28-31). - A member id is
[a-z0-9][a-z0-9-]{0,62}, minted by the lead at enrollment. It is not a hostname, not an address, and carries no routing information. - A client-supplied host is only ever a registry key. It selects among members the trust store
already holds; it never builds a filesystem path and it never becomes an address the lead dials.
This is the identical rule the session name has carried since multi-session shipped, and for the
identical reason (
bridge/sessions.ts:17-20). An unknown host is a 404, matchingunknownSession()(bridge/server.ts:174-175). ?h=composes with?s=: the session named is a session on that host. A crew link never forwards its ownhost=— a peer has no peers.
The (host, session, paneId) triple is also the cache-key shape. Today's pane ETag cache is keyed by
the NUL-joined (session, paneId) pair precisely because pane ids collide across servers
(web/src/lib/api.ts:201-203); a second Collie is another such server, so the key widens by one
component and the reasoning is unchanged.
A peer's crew routes are a 1:1 re-exposure of the routes the phone already calls, dispatched into the same handlers. There is no second handler set, no second semantic, and no Herdr vocabulary.
| Method | Path | Backs onto (Verified) | Lead treatment |
|---|---|---|---|
GET |
/crew/v1/snapshot |
GET /api/snapshot (bridge/server.ts:177) |
merged — the only merged route |
GET |
/crew/v1/pane/:id |
GET /api/pane/:id (:276) |
proxied byte-for-byte |
GET |
/crew/v1/pane/:id/history |
GET …/history (:277) |
proxied byte-for-byte |
POST |
/crew/v1/pane/:id/reply |
POST …/reply (:279) |
forwarded |
POST |
/crew/v1/pane/:id/keys |
POST …/keys (:280) |
forwarded |
POST |
/crew/v1/pane/:id/upload |
POST …/upload (:281) |
forwarded (§13) |
POST |
/crew/v1/pane/:id/close |
POST …/close (:282) |
forwarded |
POST |
/crew/v1/pane/:id/rename |
POST …/rename (:283) |
forwarded |
POST |
/crew/v1/pane/:id/focus |
POST …/focus |
forwarded — additive-optional (§7.1). Shows the pane on the peer machine's terminal; a lead that predates it never calls it, and a peer that predates it answers 404 to a lead that does |
POST |
/crew/v1/tab |
POST /api/tab (:218) |
forwarded |
POST |
/crew/v1/tab/:id/rename|close |
TAB_ACTION_ROUTE (:102, matched :234) |
forwarded |
POST |
/crew/v1/workspace |
POST /api/workspace (:225) |
forwarded |
POST |
/crew/v1/launch |
POST /api/launch |
forwarded — additive-optional (§7.1). Runs an allowlisted launchers.toml row on the peer, from that peer's own rows; a lead that predates it never calls it, and a peer that predates it answers 404 to a lead that does |
GET |
/crew/v1/launchers |
GET /api/launchers |
forwarded — additive-optional (§7.1), same pairing as above. Rows must come from the host that runs them, so this is a READ crossing the link rather than a second copy of config's launchers field, which is why that field was retired from /api/config in the same change |
GET |
/crew/v1/blobs/:hash |
GET /api/blobs/:hash |
proxied byte-for-byte — additive-optional (§7.1). The image an agent's own journal named, off the disk that holds it; a lead that predates it never calls it, and a peer that predates it answers 404 to a lead that does |
GET |
/crew/v1/config |
GET /api/config (:288) |
consumed by the lead, not proxied |
GET |
/crew/v1/hello |
— (new) | consumed by the lead: liveness + version + member id |
?session= is accepted on every session-scoped crew route with today's exact semantics (absent →
primary). It is the peer's own session registry that resolves it.
?sessions=all is accepted on GET /crew/v1/snapshot alone, OPTIONAL, added 2026-09-08
(M22/06). It widens the answer's two pane lists to every session the answering member runs, each pane
tagged with the session it came from, which is the same body that member's own /api/snapshot serves
for ?sessions=all. all is the only value with meaning, and anything else reads as absent. A member
that ignores the parameter is a correct member — its answer is then its primary session, exactly
as before — so CREW_PROTOCOL_VERSION does not move (§7.1). The lead sends it on every sweep and
narrows the cached body per request (bridge/crew/merge.ts), because the phone's poll cannot afford a
dial the lead's cache cannot already answer (§10.1). Widening is a second dimension of ONE machine
and never travels as a host: a crew request may still not name a host=, because a peer has no
peers (§4).
hello's response body is the one place both versions cross a link — the wire contract and the
answering build:
{ "protocol": 1,
"member": "peer-7f3a2c",
"version": "1.0.0-alpha.11",
"warrantGeneration": 3,
"warrantRefreshedAt": 1755600000000 }-
protocolandmemberare REQUIRED (bridge/crew/router.ts, theCREW_HELLO_PATHbranch; a reply missing either ishello: malformed response body,bridge/crew/peer-client.ts). -
versionis OPTIONAL, added 2026-08-12: the answering build's own version string, exactly whatcollie versionprints (cli/context.ts'scollieVersion—1.0.0-alpha.11, or1.0.0-alpha.11+ab12cd3when a build stamp is present). An absent field means "a build older than this amendment", never an error (§7.1). The request side gains nothing: the surface that renders skew already dialshelloin both directions —collie crew statusprobes every member on a lead and probes the lead on a peer — so the field rides an exchange that already happens there. The lead's poll (§10.1) deliberately does not dialhello— it dialssnapshot, and gains no version leg: N extra round trips per poll to re-learn a fact that changes only on restart would be §10.1's budget spent on nothing. That road was taken on 2026-09-04, exactly as written here: the sameversionstring now also ridessnapshot's response as an additive-optional sibling of the body, per §7.1's class rule and with no second dial. It is registered in §19 ("The running version, in the same seat"), and it is the field the lead's ledger is actually kept current from — a probe fires only after a sweep has timed out (§10.4), so on a healthy crewhellois never dialled at all. -
warrantGenerationandwarrantRefreshedAtare OPTIONAL, added 2026-08-20 (§18): the warrant this member holds, as a monotonic integer and an epoch-millisecond timestamp. They are sent as a pair or not at all, and an absent pair means "this member holds no warrant, or is a build that does not know about warrants" — never "up to date" (§7.1). A half-reported pair is exactly as unknown as an absent one. Both readings make the lead push the current warrant on its next sweep, which is the closed direction: a needless push costs one small body, where reading silence as currency costs a member that never receives the operator's designation at all. The same pair rides/crew/v1/snapshot's response, beside the snapshot body rather than inside it — and §19'supdatePreflighttakes that same seat, for that same reason — that body is the one this collie serves its own browser, and a crew-only fact has no business in the browser's shape.snapshotis what the lead's poll already dials (§10.1), which is what makes the comparison cost no extra round trip. -
warrantActiveGenerationis OPTIONAL, added 2026-08-20 (§18.17): the warrant generation that member's listener activated when it bound, which is the second of §5's two phases and the only half the lead cannot observe. An absent field means "nothing is active there, or this build cannot say" — never "armed" (§7.1), and that reading leaves the lead on the lower bound in its owncrew-ops.json, which is the pre-amendment behaviour unchanged. It rides/crew/v1/snapshot's response too, beside the body rather than inside it, for the warrant pair's reason. It names no secret: one integer, and one the caller itself issued. -
pairingDigestis OPTIONAL, added 2026-08-20 (§18.14): a digest of the synced paired-device registry this member holds, andnull/absent on every member that holds none — which is every peer that is not the deputy. An absent field means "nothing synced here", never "up to date" (§7.1), and both readings make the lead push, which is the closed direction for the same reason the warrant pair's is. It rides/crew/v1/snapshot's response too, beside the body rather than inside it, becausesnapshotis what the lead's poll already dials. It names no secret: a hash over labels, token hashes and creation stamps — a digest of digests — and it is admissible here formember's reason, being already knowable to anyone who has cleared both factors. The value is opaque to its reader, which only ever compares it for equality with its own. -
pairingCollisionis OPTIONAL, added 2026-08-20 (§18.14): the labels this member's own paired devices share with the synced registry it holds. Absent or empty means "no finding" (§7.1), and it is re-derived from disk on every answer, so it appears while it is true and clears the moment the operator frees the name. It rides/crew/v1/snapshottoo, besidepairingDigest. It names labels the operator chose and nothing else — no hash, no token, no count — and it is a finding, never a refusal: the sync it describes has already been applied, because a receiver that refused it would be holding a revoked credential live at its own standby door. -
muxis OPTIONAL, added 2026-09-08 (M22/03): the block this member's own/api/configpublishes about the multiplexer underneath it — its registry name, its capability answers, its refused key spellings, its adapter notes, its space capacity and its declared topology latency. An absent field means "use the lead's answer", never "every capability present" (§7.1). That reading is today's behaviour exactly: the phone reads the lead's/api/configonce and applies it to every pane on every host, so a peer that publishes nothing keeps producing the answer it already gets. The lead caches what it learned beside the rest of that member's state and replaces it on every successful hello, never merging, so a restart or an adapter change cannot leave a stale key behind; the lead then answers/api/config?host=<member>from that cache and dials nobody. It rideshellorather thansnapshotbecause it changes only when that member's bridge restarts, andsnapshotis polled every 1500 ms (§10.1).logoUrlis deliberately not carried: the mark is a path, and a path only answers on the machine that serves it. It names no secret, and it is admissible here formember's reason, being the same block that member already serves to every read client of its own front door.
hello gains nothing else. It is what an admitted member uses to confirm a link, so it must not
become a place to learn something an unadmitted caller wants; a version is admissible there for the
same reason member is — it is already knowable to anyone who has cleared both factors. A warrant
generation is admissible for the same reason, and it names no secret: an integer and a timestamp.
Deliberately not on the crew surface, because they are properties of the collie the phone talks to
rather than of a herd: POST /api/subscribe (:303), POST /api/notifications/snooze (:318),
GET|POST /api/notifications/prefs (:340), POST /api/update/check (:367). Push subscriptions
live on the lead; notification policy is one crew-wide setting the lead owns; update checking is
per-machine and is the operator's business on each. A peer's own /api/* surface still has them for
its own operator, when that peer is being used directly.
No upload-read route exists on either surface. POST …/upload stores a file and returns its
absolute local path (bridge/server.ts:1090); nothing serves it back over HTTP today (verified: the
pane route family bridge/server.ts:93 has no read action for uploads). If one is ever added, it is a
proxied byte-for-byte read like the mirror, and it reads from the owning peer's disk.
The membership routes are a separate, smaller table. They are not re-exposed phone routes and
never will be — they carry no pane data, take no ?session=, and are addressed to the collie rather
than to anything it fronts. They exist because three operator verbs are otherwise undeliverable:
| Method | Path | Sent by | Meaning |
|---|---|---|---|
POST |
/crew/v1/enroll |
a joining machine | The exchange of §8.2. Admitted by the token, not by the two factors — the joining peer holds neither yet. |
POST |
/crew/v1/secret |
the lead | Hands a peer the rotated crew secret (§8.4). Refused unless the caller is this collie's own lead: the secret is crew-wide, so any other admitted member accepting one could lock the lead out of its own crew. Authenticated by the outgoing secret and carrying the incoming one — there is no instant in which both are accepted. |
POST |
/crew/v1/lead |
the member being promoted | "The member calling you is the lead now" (§14). The old lead demotes itself and answers with its roster (the only way the new lead can pin members it has never spoken to); a peer re-pins and keeps its id and the crew secret. A member may only claim leadership for itself — and a claim is not self-authorising: the lead demotes only against a live operator approval (§14). |
POST |
/crew/v1/leave |
any member | The caller removes itself from this collie's roster (§8.4). The member id is the admitted one, never a body field, and a second call is still 200 — the operator's question has the same answer either way. |
POST |
/crew/v1/pairing |
the lead | Syncs the lead's paired-device registry — hashes only — to the deputy and to nobody else (§18.14). Refused unless the caller is this collie's own lead AND this collie holds a verified warrant naming itself. A label that collides with one of this machine's own paired devices refuses the sync with code: "pairing_label_collision", naming the labels; it is never silently renamed. |
POST |
/crew/v1/takeover |
the deputy | The witness question, then the re-pin (§18.16). Two-phase, with phase additive-optional and absent meaning probe — the reading that changes nothing. It is the one route a caller admitted as the deputy may use, and like warrant it has two kinds of recipient: arriving at a collie that still believes it leads, a probe answers lead_is_alive (it is the lead, and it is answering) and a commit is a deposition (§18.12). |
POST |
/crew/v1/warrant |
the lead | Delivers or refreshes the warrant naming the crew's deputy, with the deputy's certificate beside it (§18). Refused unless the caller is this collie's own lead — the same role check secret carries, for the same reason. A member that 404s this route is a pre-amendment build: not warrant-capable, and therefore not takeover-capable. That is a closed reading and a named crew status finding, never an error. It is also the one route with two kinds of recipient — arriving at a collie that still believes it leads, a warrant it signed itself is a deposition rather than a push to store (§18.12), exactly as /crew/v1/lead is one fact arriving at two kinds of recipient. |
Everything except enroll sits behind the same two factors as the rest of the prefix, and each
carries a role check on top: admitted and allowed to do this are different questions.
Which routes may be authenticated by a §8.6 signature is a closed set — leave, lead, hello
and, since 2026-08-20, warrant and takeover. It is exactly the routes that travel peer → lead, the one
direction where the transport cannot pin (§8.1), and warrant joined it for the delivery that
matters most: a new lead telling the old one that the crown has moved (§18.12). The proxy surface is
not on the list and must not be — those calls run lead → peer over a pinned handshake, and admitting a
signature there would mean hashing a request body on the security path, turning a streamed upload
(§13) into a buffered one. enroll is not on it either: at that instant the joiner is pinned by
nobody (§8.2).
Reserved paths. /crew/v1/ must never collide with /auth, /auth/* (reserved for a fronting
proxy, bridge/server.ts:1347, matched :383) or /cdn-cgi/. It is also denylisted in the service
worker's route table (web/src/lib/sw-routes.ts) — a browser never issues a crew request, so a
browser must never be able to cache one.
/standby joins that reserved set (added 2026-08-20, §18.15). It is not a crew route and never
travels a crew link — it is the deputy's own second listener, on its own port — but it is reserved on
the front door and denylisted in the same service-worker table, and for a sharper reason than
hygiene: in the same-origin failover deployment the phone's first hit on the bad day is an installed
service worker minted from the lead's origin, so a precached app shell there is the difference
between reaching the takeover page and staring at the UI of the collie that just died.
Every request on a crew link, and every response:
| Header | Direction | Meaning |
|---|---|---|
Authorization: Bearer <crew-secret> |
request | The crew-wide shared secret (§8). Required on every request including hello. |
X-Crew-Protocol: 1 |
both | Protocol version. Required on every request and every response (§7). |
X-Crew-Member: <member-id> |
both | Who is speaking. On a request, the lead's id; on a response, the peer's. Informational — identity is proven by the pinned certificate, never by this header. |
X-Crew-Device: <device-id> |
request | The operator's device identity, forwarded for the peer's audit trail (§12). Absent when the lead's device gate is off. |
X-Crew-Preflight: fresh |
request | Optional, added 2026-09-04 (§19). On GET /crew/v1/snapshot only: a REQUEST that the answering member re-run its own collie update --check --local before it answers. fresh is the only value with meaning; anything else reads as absent. A member that ignores it is a correct member — its answer is then simply older, and asOf says so. Honoured at most once per PREFLIGHT_TTL_MS per member. |
X-Crew-Lead-Release: <x.y.z> |
request | Optional, added 2026-09-04 (§20). On GET /crew/v1/snapshot only: the bare version the LEAD is itself running, sent only while that version is a strict release and the lead's own health gate has settled it. Absent means the lead is on a dev or prerelease build, or is mid-run — and absent means the receiving member does nothing. It is a statement about the sender and carries no ref, no URL and no command. |
X-Crew-Update-Turn: <member-name>;<run-id> |
request | Optional, added 2026-09-04 (§20). On GET /crew/v1/snapshot only: who may take that release now, and the id of the UpdateRun the operator confirmed on the lead. Sent to at most one member at a time. A member ignores a turn that does not name itself. Absent means it is not this member's turn. |
X-Crew-Received-At |
response | Omitted deliberately. A peer's clock is never trusted for freshness — the lead stamps its own receipt time (§10). |
The crew surface carries no Origin and no Host expectation: checkAccess()
(bridge/server.ts:1113-1151) is a browser gate — same-origin comparison, optional
Tailscale-User-Login, optional device header — and a crew request satisfies none of its
preconditions. The crew admission path is separate from checkAccess(), never a widening of it.
Consequences, stated so nobody has to infer them:
- A request arriving on
/crew/v1/*is admitted only by the two crew factors (§8). Browser credentials never admit one. - A request arriving on
/api/*is admitted only bycheckAccess()/guard()(bridge/server.ts:1180-1187). The crew secret never admits an/api/*request — it is not a bypass of a gate the same request would otherwise have faced. - A phone request for a peer-scoped resource passes the lead's gates first —
guard(req, cfg, "read"|"write")exactly as today, includingdeviceAuth()(:1216-1223) — and then the crew link. A crew link is never an authorisation upgrade.
X-Crew-Protocol is an explicit integer on the wire, never inferred from the app version. Lead
and peer are separately updated machines, so skew is the steady state, not an edge case.
GET /api/config reports a build id (bridge/server.ts:288-300) but that is a build, not a contract.
-
v1 window is exact:
1talks to1. There is no forward-compatible range until there is a version 2 to define one against; pretending otherwise ships an untested compatibility claim. -
A peer receiving an unknown or mismatched version refuses with
409 Conflictand a body naming both sides — never a bare 4xx, never a partial answer:{ "error": "crew protocol mismatch", "code": "protocol_mismatch", "expected": 1, "received": 2 }(The
error-string field matches today'sjsonError()body,bridge/server.ts:1244-1251;codeand the version fields are the crew additions.) -
The lead applies the same rule to a peer's response header: a reply with a version it cannot read is a mismatch, not a parse error.
-
A missing header and an unreadable one are two different findings, and the lead may not conflate them. (added 2026-09-08) An unreadable or foreign header means the lead learned something: this peer names a grammar this build cannot speak, and that cannot resolve on its own. A missing header means the lead learned nothing about the peer's version. A process part way through a proxy in front of a peer that is restarting, a
502from a reverse proxy, a404from a wrong path, and a solo collie that serves no crew route at all (§11) all answer with no header, and none of them is a skew. (A peer dialled DIRECTLY while it restarts refuses the connection, which was never on this path.)- A response with no
X-Crew-Protocolis thereforeunreachable(§10.2), with the HTTP status in the reason so the operator can tell a proxy from a peer. - The rule is bounded by a DURATION, not by a count of answers: once a member has been
answering without a header for longer than the lead's patience window, sixty seconds, it falls
back onto the incompatible ladder, so a permanent stranger is not dialled at the poll rate for
ever. Any answer that names a version closes the window. The bound is lead-local, never on the
wire, and moving it needs no protocol change. It is a duration because the gap between two
answers is not fixed: the sweep runs between 1500 ms and 12 000 ms and the same client also
carries
hello, proxied reads and the warrant push, so a count would be tightest exactly when the operator is watching. - A bare
401keeps its own branch and its own reason (§8.5). It is alreadyunreachable, it already names the cause the operator can act on, and it opens no window. - This rule is about a peer's RESPONSE. The receiving side is unchanged: a REQUEST that
arrives with no
X-Crew-Protocolis still refused with409andprotocol_mismatch(bridge/crew/admission.ts), because a caller that names no version has not met §7's exact-match window.
- A response with no
-
An incompatible peer is a distinct state from an unreachable one (§10). It is not retried on the poll cadence, its sessions are shown from last-good state marked incompatible, and the reason string is surfaced verbatim in the UI and in
collie crew status.
Two version numbers ride a crew link and they are not the same kind of thing. X-Crew-Protocol
is a contract: it says which grammar the bytes are in. A Collie build version (1.0.0-alpha.11) is
a fact about a running process: it says how new the code answering is. Lead and peer are separately
updated machines, so build skew is the steady state (§7), and this section is the class rule for it.
-
The protocol integer is the ONLY thing that refuses. §7's exact-match window guards actual wire incompatibility, and
admission.tsenforces it before a handler runs. A build-version difference refuses nothing: no route behaves differently, no response degrades, no code path branches on it. A crew that goes dark because two machines disagree on an alpha number has traded an annoyance for an outage.One surface reads a build version as a refusal, and it is not the wire. The lead's own preflight (
collie update --check, which walks the members over the operator's ssh) reds theversioncheck when a member's build is below the protocol floor, the oldest build that speaks this document'sCREW_PROTOCOL_VERSION(PROTOCOL_FLOOR_VERSIONincli/update-check.ts, ADR 0045). That is a judgement about whether a crew update can finish, made before anything is started, and it changes no route, no response and no code path on the link. On the wire the rule above still stands whole: a build-version difference refuses nothing.The fork worth naming, because it will be re-proposed: shouldn't a skewed member be refused, to be safe? No. Refusing is only the safe move when the alternative is a wrong answer, and inside one protocol version there is no wrong answer to prevent — which is true only because of the next rule, and stops being true the moment that rule is broken.
-
Every addition inside a protocol version MUST be additive-optional, with absent-means-closed semantics. A new field is optional on the wire; a member that does not send it is read as not claiming the thing, never as claiming it permissively. Concretely: an absent field never grants, never approves, never widens, and never makes an older member's silence read as consent. §14.6's approval field is one instance (no approval field ⇒ no live approval ⇒ refuse);
hello'sversionis another (absent ⇒ "older than this amendment", rendered as such); §18'swarrantGeneration/warrantRefreshedAtare a third (an absent pair ⇒ "holds no warrant", which makes the lead push — never "already current"); and §18's/crew/v1/warrantroute is a fourth (a404⇒ the member cannot hold a warrant, which is the closed reading of every question that route answers); and §18.10'slead_conflictis a fifth, and the only one that is a new answer on an existing route rather than a new field. It is still additive in the sense that matters:409is a status this document already assigns to "we do not agree about who we are talking to", an older dialler renders an unexpected409as a refusal rather than as data, and it never turns a refusal into a grant. An addition that cannot be expressed this way is a version-2 change and takesX-Crew-Protocolwith it — it does not get to ship inside1with a compatibility claim nobody tested.This is what makes a member running older code behind, not incompatible: it declines new optional fields, and declining is a closed reading, so there is nothing a newer member must refuse it over.
-
The error bodies the shared session routes serve gained optional
codeanddetail(added 2026-08-24,bridge/error-codes.ts): a stable machine name for the refusal and the named values its sentence was built from, beside the Englisherrorthat route always sent. The sentence is unchanged and stays the fallback, so a peer or a lead that ignores both fields behaves exactly as it does today, and a code a reader does not recognise reads as no code — which renders that same sentence.CREW_PROTOCOL_VERSIONstays1. -
A member's own preflight gained an optional
installKind, and a peer's leg gained thepackage-managedstate (added 2026-09-06, M17 spec 02). Both are additive-optional with the closed reading this section requires.installKind(§19) names how that machine is installed; absent means unknown, and unknown counts as NOT packaged, so a member older than the field is driven exactly as it was before it existed, and a kind this build does not recognise reads as absent.package-managedis a sixth value of the peer-leg stateGET /api/update/checkreports; it is terminal in the same sensedoneis — the lead's turn queue never waits on it and a run completes with one present. A reader that does not know the value renders it as it renders any other unknown state, which is safe because the value is only ever read, never branched on to take an action: the action it stands for is "do nothing to that machine".CREW_PROTOCOL_VERSIONstays1, no new route, no new verb and no new header. -
A pane gained an optional
cache(added 2026-09-13, M28 spec 02). It carries a prompt-cache reading for that pane: a state word, the expiry, the TTL in seconds, the rule id, a confidence word and two timestamps. Additive-optional with the closed reading this section requires: absent means nothing has been measured, which the phone renders as nothing at all. A 1.8.x peer simply omits it, and a lead that predates it ignores it, so neither side refuses the other over it. Nothing inbridge/crew/merge.tschanges:isPaneWirecheckspaneId,statusandworkspaceNumber, anduntagPanestripshostwith a rest spread and keeps every other key, so the field rides a peer's contribution for free.The number is computed on the machine the pane lives on, with that machine's own rules, which is why the chip is true where it is rendered even though a peer may hold its own
cache-rules.tomloverride. The rule CATALOG behind it (GET /api/cache-rules) is deliberately not forwarded: quoting the lead's catalog for a peer's number would cite a page that peer never read, so the pane sheet says where the number was read instead. Forwarding it is a follow-up spec, not a bullet on this one.CREW_PROTOCOL_VERSIONstays1, no new route, no new verb and no new header. -
An addition a lead has no reader for is INERT, not merely tolerated — measured, not assumed (2026-09-08, §16's version-skew leg). This section's promise used to rest on a unit test with a stand-in field. It has now been walked with the two real builds: a 1.6.0 lead binary, leading the lab's own crew, over a tmux member and a zellij member both built from M22, whose
hellocarries this milestone'smuxblock with the whole capability table in it. What was measured is stronger than "it stayed up". The lead's published bodies for a member on M22 and for the same machine, same multiplexer, same panes rebuilt on 1.6.0 are field-identical — every key name and every value across/api/snapshot's panes, tabs, spaces, sessions andserversrow and/api/crew's member row — except the one string this section exists to report,version. Nothing from the newer member reached the older lead's output, and nothing the older lead needed was missing from it. Its journal over the whole run held noerror, nounknownand nounexpectedline, and the onlywarn-class line was the lab's own wide-bind notice, which predates the leg. The test that pins this now names the field rather than a stand-in (bridge/crew/peer-client.test.ts), so a change that makeshello's reader depend onmuxfails at commit time. Read the additive fields this milestone shipped in that light:muxonhellois ignored by an older lead;sessions=allon the sweep is a param an older lead never sends, and its absence is already pinned as today's narrow answer (bridge/crew/router.test.ts); andlinkStateis computed by the LEAD and published on ITS surface, so an older lead simply never emits it — its host row wentreachable: falsewithhealth: "unreachable", a reason, nolinkState, and the peer's panes last-good, which is §10.2 without §10.2's presentation split and is precisely what that lead does with a member of its own age. -
Skew is an observation, and it is rendered.
collie crew statuscompares each member's reported version against this build's and marks a difference as awarn:-class finding naming both versions and the remedy. Which side is behind picks the remedy, and the other one is not printed (added 2026-09-08, §16's version-skew leg found the unconditional form): a member behind this lead is levelled withcollie crew update <member>here, over the operator's own ssh (ADR 0016); a member AHEAD of this lead means the lead is the older machine, so the line says so and namescollie updateHERE —crew updatefrom an older lead pushes ITS build outwards and would take that member backwards, which is why the wrong remedy is worse than none. Two different strings for one semver — a build stamp, anunknown— have no direction, so the warn names both versions and no command. A member that answershellowithout the field renders honestly as pre-amendment (e.g.version pre-1.0.0-alpha.12 (not reported)), never asunknown-shaped noise and never as an error. Theincompatiblestate stays reserved for §7's protocol mismatch; nothing here produces it. -
The observed version is NOT persisted. It lives in the lead's in-memory health registry beside reachability (
PeerState,bridge/crew/registry.ts) and is discarded on shutdown and onprune()exactly as reachability is. A version describes a process, and the process is what a restart changes: a persisted version would survive the update it is meant to report and state a falsehood with the authority of the trust store. NoTrustedMemberfield, andTRUST_STORE_VERSIONstays1(§14.6's reasoning for not bumping it applies unchanged). -
Where the responder gets the string. The CLI already has it (
collieVersion(), fromweb/dist/build-info.jsonfalling back toherdr-plugin.toml). The bridge does not — it reads only the bundle's build id (bridge/server.ts'sbuildId()), which is not a version. The implementation MUST therefore thread the version intoCrewRouterDepsat boot, resolved once by whoever constructs the router (bridge/index.ts), using the same rule the CLI uses so the two never print different strings for one machine. It MUST NOT read the manifest per request: a per-request disk read on the crew's most frequent route, to answer a question whose answer cannot change without a restart, is a cost with no truth behind it.
Compatibility of this amendment, in §14.6's terms: an additive optional response field, no new
route and no new object. An old member answering a new prober omits it and is rendered as
pre-amendment. A new member answering an old prober sends a sibling the old parser ignores — verified:
PeerClient.hello reads body.member and body.protocol by name off a Record<string, unknown> and
passes unknown keys over without inspection. CREW_PROTOCOL_VERSION stays 1. No trust-store
change, no migration, no re-enrollment. Adoption is per machine and needs no coordination: each side
starts reporting when it is updated, and until then the other side says so.
Collie holds no TLS material and mints no credentials today (verified: searched bridge/ for tls,
cert, pem, secret — nothing). Enrollment introduces the first private key Collie owns.
Every crew request must satisfy both, on the crew listener, or it is refused:
- Pinned mutual TLS. Each collie generates a self-signed certificate. Enrollment exchanges and pins the two fingerprints; thereafter, an unpinned certificate is simply not that member. No CA, no directory, no overlay network — the Syncthing model. Pinning is pairwise.
- The crew secret, presented as
Authorization: Bearer. The secret is crew-wide (one value every member holds), not pairwise.
Neither alone admits a request: pinning survives a leaked secret, and the secret survives an unexpected certificate chain appearing in front of a peer. The asymmetry is deliberate — pairwise pinning is what contains a single compromised member's ability to impersonate another; a crew-wide secret is what makes rotation a single operation rather than N².
Certificates are long-lived (10 years) and expiry is not a trust boundary — the pin is. A crew whose members are rarely all online cannot depend on a renewal handshake that may never get a window.
A refusal is indistinguishable to the caller. Absent secret, wrong secret, unpinned certificate
and unknown member all produce the identical response — 401 with body {"error":"unauthorized"},
no code, no timing branch, no hint about which factor failed. An unauthenticated caller learns only
that something is listening.
Amended 2026-08-07 — where the certificate factor is enforced, and in what shape.
On a peer's listener the TLS factor is enforced at the handshake. The peer's
Bun.serveis built withca: [<its lead's certificate>] · requestCert · rejectUnauthorized, so BoringSSL verifies the presented chain before a byte of HTTP exists. An unpinned certificate, or none, is a transport refusal — not the uniform 401. That is a deliberate narrowing of the paragraph above, forced by a measured fact: Bun 1.3.14 can enforce a client certificate onBun.servebut exposes no way to read one (no accessor onServer, onRequest, or throughnode:https), so a fingerprint cannot be compared in the router. It reveals less, not more — §8.5's "learns only that something is listening and speaks TLS" survives intact, and the uniform 401 still covers the secret factor and everything above it.Two consequences follow, and both are load-bearing:
- A peer's
calist holds exactly one certificate (amended 2026-08-20 — see below). A peer's roster holds exactly one member (§8.2 step 4), so an admitted connection can only be its lead. Admission therefore takes the transport's verdict as a boolean attestation (transportPinned) set by the code that built the listener, never read from a header — and resolves it to the pinned lead. A peer that cannot build its anchor sets itfalseand refuses everything: down, never single-factor.- The lead's own listener pins nothing, and cannot. Its crew surface rides the front door, and
tailscale serve— or any conforming reverse proxy (docs/deployment.md Variant C) — terminates TLS before the process sees the connection. No client certificate survives to a lead under any design. The peer→lead direction re-establishes the second factor at the application layer instead: §8.6.There is no live re-pin.
server.reload({ tls })does not swap a pinnedca; a membership change takes effect through the restart every membership verb already performs.
COLLIE_PEER_BROWSER=1and a pinned listener are mutually exclusive. A browser cannot present the lead's client certificate, so on a pinned peer that flag's surface is unreachable. The peer warns and pins anyway — the crew's factor is not weakened for an opt-in convenience.Promotion is bounded by this. A peer pins its current lead, so a newly promoted member's handshake is refused by every other peer until that peer re-joins. With two members promotion is unaffected (the claim goes to the old lead, over §8.6). With three or more, the peers that are not the old lead must be re-enrolled — which is the rule §14 and §8.4 already state for an unreachable member, now reached for a second reason.
Amended 2026-08-20 — a peer's
calist holds AT MOST TWO certificates, and the second is named by a warrant.The rule above becomes: its lead's certificate, plus at most one more — the deputy named by a warrant this peer has verified against that same lead's key (§18.5). Every other reading of a warrant leaves the list at exactly one anchor, which is the pre-amendment behaviour: a missing, malformed, foreign, unsigned, expired or revoked warrant, a certificate that is not the one the warrant's fingerprint names, or a warrant naming this peer itself or its own lead. Never fewer anchors than before — the lead's own certificate is never in question and is never dropped, so an existing lead's handshake is unaffected and this is not a wire change.
The boolean does not survive a second anchor, so it is not used with one. With one anchor,
transportPinnednamed a unique member and was sufficient rather than lossy. With two it names one of two, and Bun still exposes no accessor for the certificate a caller presented — so reading it as "the lead" would let a deputy that merely completed the handshake be taken for the lead on every route. A two-anchored peer therefore resolves its caller by SIGNATURE, never by the transport's boolean:
- Every lead→peer dial carries a dial attestation (§8.6): base64 ECDSA-P256-SHA256 over a domain-tagged string binding the method, the path, the timestamp and the member being dialled, in
X-Crew-Dialbeside the existingX-Crew-Timestamp. It covers no body, so a streamed upload (§13) stays a stream and it can therefore ride every route rather than a closed set.- Identity is whichever anchored certificate verifies it — the pinned lead's, or the deputy's. A verified §8.6 request signature still wins outright where one is present; it is the more specific claim.
- An unattested request is refused on a two-anchored peer, whoever it claims to be from. A listener that cannot tell its two callers apart must not guess.
- A single-anchor peer is unchanged, byte for byte — the boolean still resolves to its lead, an unattested dial is still admitted, and no crew that has never named a deputy sees any of this.
This is additive, and the reason is the ordering. The only lead that can face a two-anchored peer is a post-amendment build, because a second anchor exists only where a warrant was issued and pushed — and only a post-amendment lead can issue one. So there is no build that could be locked out by the requirement that did not create it. An older peer ignores the extra headers and loses nothing it had.
What a second anchor buys a compromised deputy is therefore a completed TLS handshake and nothing behind it. Every route this specification defines refuses a caller admitted as the deputy; the takeover and witness routes will declare themselves as accepting one, and until they exist the set is empty. §8.5's mitigation still applies to what a takeover would grant — make the deputy the second machine you most trust — but it is no longer load-bearing for the pre-takeover case.
Run on the peer, once.
-
The operator mints a token on the lead (
collie crew invite). The token is single-use and short-lived (10 minutes). -
The peer dials
<lead-address>, presenting the token. The token authenticates the exchange, and nothing after it. -
The handshake transfers, and both sides persist:
Item Direction Persisted by Peer's certificate and its fingerprint peer → lead lead (pinned) Lead's certificate and its fingerprint lead → peer peer (pinned) Crew secret lead → peer both Crew identity (crew id + human name) lead → peer both Peer's member id (minted by the lead) lead → peer both The address the lead will dial, and the address the peer will listen on negotiated both Note, added 2026-09-09 (1.7.0). The default human name changed. A crew minted without
--nameis calledcollie crew; before 1.7.0 it wascollie pack. The field itself is unchanged. The name is display data keyed by the crew id, and a crew that already has a name keeps it. -
The lead's roster gains the peer; the peer's roster gains exactly one entry — its lead.
<lead-address> is whatever the operator can reach. Any network: tailnet, LAN, WireGuard,
someone else's overlay, an SSH tunnel. Collie owns authentication; the operator owns reachability.
There is no discovery, no enumeration, and no overlay-network integration — ever.
Note, added 2026-08-13 — what a lead advertises when the operator does not say. The address a lead hands a joiner (
collie crew invite,collie crew add,collie promote) is resolved in this order: an explicit--address, taken verbatim; thenCOLLIE_PUBLIC_URL, reduced to its origin (scheme + host + port — the crew link mounts at/crew/v1/*off it, so a path is dropped with a warning, and a value that does not parse warns and falls through); then this node's Tailscale name. A machine whose real ingress is a reverse proxy (docs/deployment.md Variant C/E) therefore states that ingress once in config instead of on every invite — a derived tailnet name is silently undialable from a peer under a one-way tailnet ACL, and nothing in the enrollment names that as the cause. A peer's own listener address is never taken fromCOLLIE_PUBLIC_URL: a peer publishes no front door (§3, ADR 0013), and a public URL is a front door by definition.
Amended 2026-08-07 — what actually authenticates an enrollment, stated rather than implied.
The token and the payload. Not the transport. Trust-on-first-use, at the moment of
join.This was always true and used to read as though mutual TLS covered it. It cannot: enrollment is answered by the lead, whose surface sits behind a TLS-terminating front door, so no client certificate reaches the process (§8.1's amendment). And it could not be otherwise even in principle — at this instant the joiner is pinned by nobody, which is the entire reason an enrollment exchange exists.
So the guarantees are exactly these, and no more:
- the single-use, ten-minute token the operator carried out of band is what vouches for the certificate in the payload;
- the certificate travels with its fingerprint, and each side refuses a payload whose certificate does not hash to the stated fingerprint — so what is pinned is provably what the sender will present, and a pin can never be persisted in two disagreeing halves;
- the certificate itself is transferred, not only its hash. A hash cannot be enforced: BoringSSL anchors on certificates, and Bun offers no fingerprint-pinning hook. A member holding only a hash could compare a pin it had no way to check.
Everything after the exchange is two-factor (§8.1, §8.6). The exchange itself is one factor, on purpose, and it is the operator's ten-minute window that bounds it (§8.5).
Note, added 2026-08-07 — the lead must be restarted after an enrollment, and is told to be.
The enrollment lands in the running lead's trust store, through the lead's own
/crew/v1/enroll. That store is read once per process, at boot: the mode, the roster the lead sweeps and the pinnedcaa peer's listener enforces are all built from that one read. So a lead that answers its firstcollie joinpersists the peer and goes on merging nothing until it restarts.collie crew inviterestarts the lead so it can answer the invite; the enrollment arrives afterwards, and no restart follows it.v1 does not re-wire in place, and this is the decision, not an omission. Re-reading the store into a live process would mean a second startup path running concurrently with the first — and
server.reload({tls})does not swap a pinnedcaat all (M4/08's transport investigation), so a peer's own listener could not be re-pinned without dropping the port. What v1 does instead is refuse to be silent about it:
- the bridge records the roster it wired at boot in
<stateDir>/crew-runtime.json(bridge/crew/staleness.ts), and logs when a membership change lands under it;collie crew statuscompares that marker to the store and prints "enrolled but INACTIVE", naming the members that are enrolled and not being served, and thecollie restartthat fixes it;collie joinends by naming the same restart, on the lead — the joining machine restarts itself, and it is the only party in a position to tell the operator about the other side.The marker is written only by an instance that has a trust store, so §11's zero-tax contract is untouched: solo still writes nothing.
Amended 2026-08-08 — the invite carries the lead's fingerprint, so the lead is authenticated to the joiner (closes F1).
The 2026-08-07 amendment above admitted the gap plainly: the exchange authenticates the joiner to the lead (the token) and pins the lead trust-on-first-use, with nothing authenticating the lead to the joiner. A man-in-the-middle on the enrollment path — or a mistyped/rebound
<lead-address>— could capture the token, relay it to the real lead as its own enrollment, and answer the joiner with its own certificate as "the lead", pinned permanently in both directions.The fix is the Syncthing model: the operator-carried token is now
<token>.<lead-fingerprint>, where the suffix is the lead's own certificate fingerprint (public material). The wire is unchanged —EnrollRequest.tokenis still exactly<token>, and the lead still stores only its hash — the fingerprint travels only in the operator's out-of-band paste.joinrefuses a lead whose certificate does not hash to the invited fingerprint, before anything is pinned or persisted, and fails closed on an old-format token that names no lead. It is the fingerprint, not the transport, that authenticates the lead — sohttp://remains allowed on a trusted network.
Amended 2026-08-10 —
joinrefuseshttp://without--insecure.The enroll exchange carries the invite token to the lead and returns the crew secret to the joiner. Over a plaintext hop both cross the wire in the clear, so an on-path attacker who reads the token can self-enroll their own certificate as a member — the lead admits on the token alone — before the honest joiner spends it, and walks away holding the crew secret and a pinned link. F1's fingerprint pin (above) authenticates the lead to the joiner; it does not defend the lead against a token-thief racing the spend. So
collie joinnow refuses anhttp://address unless the operator passes--insecure, making the trusted-hop assumption explicit rather than implied. A scheme-less address is treated ashttps://, and an unreachable one whose scheme was assumed says so. The wire is unchanged andCREW_PROTOCOL_VERSIONis not bumped.
ps -eo args and /proc/<pid>/cmdline (mode 444) are world-readable — this is not theoretical; it is
the concrete failure recorded in ADR 0001. Therefore:
- Tokens and the crew secret are read from stdin or a 0600 file, never from a command line
argument and never from a long-lived process's environment.
collie join <lead-address> <token>is written that way for readability; the token argument accepts-(stdin) and@<path>, and the literal form warns. - At rest, crew material follows the discipline
push-subscriptions.jsonalready uses: atomic temp-file-then-rename, file 0600, directory 0700 (bridge/push.ts:187-192), understateDir(bridge/config.ts:200-203:HERDR_PLUGIN_STATE_DIR??COLLIE_STATE_DIR?? the user state dir).
Run on the lead. Reissues the crew secret and distributes it to every reachable peer in one operation.
- There is no grace window and no rollback secret. The old secret stops being accepted the moment rotation completes on a member. A rotation whose whole point is to invalidate a leaked value cannot keep honouring it for a stated period.
- Order follows from that. The rotation lands on the lead first, so the lead never hands out a
value it does not itself hold; distribution then dials each peer with the superseded secret, which
is the one that peer still checks. Between the two steps the lead's ordinary poll of an undelivered
peer fails — one interval of
stale(§10.2), which is the price of not keeping a leaked value alive. - A peer offline during rotation is dropped to
unenrolled. The lead marks it so; the peer, next time it is dialled, fails both factors and stays quiet. Recovery is deliberate and explicit: the operator runscollie joinon that peer again with a fresh token. There is no grace window: any peer offline at rotation time is dropped and must re-join, and that is precisely the cost the remedy for a suspected secret leak —collie crew rotate— pays to invalidate the leaked value. collie crew statusshows, per member, whether it has picked up the current secret — rotation is not "done" as a fire-and-forget; it is a state you can read.collie leave(on a peer) drops its roster entry and its pinned material; on the lead,collie crew remove <member>unpins and forgets. Either side alone is sufficient to end the link — a lost disk on one end is handled by removing the member on the other.
Stated plainly, because a crew link is remote shell access to a second machine.
-
A compromised peer reaches: the crew secret (so it can authenticate to the lead as a member) and its own machine's terminals, journal, uploads and audit. It cannot impersonate another peer — pinning is pairwise, and the lead dials a pinned certificate, not a name. It can serve the lead arbitrary snapshot and pane content, which the lead renders; that content is already treated as attacker-influenceable and rendered as React text nodes under a strict CSP (
bridge/server.ts:77-80,ARCHITECTURE.md§6).The promotion path used to reach past "its own machine". It is closed; the attack is kept here because the closure is only legible against it (F2, amended 2026-08-08, closed 2026-08-11). Until this amendment,
POST /crew/v1/lead(§14) accepted a signature-verified self-claim from any enrolled member with no operator consent on the receiving lead — the wire could not tell an operator-runcollie promotefrom a compromised peer running the same verb (bridge/crew/router.ts,newLead). The signature authenticates which member is claiming, never that an operator willed it. Two consequences followed, and neither was contained by "its own terminals":- (a) Denial of service against the crew. The claim forced a leadership change no operator
consented to: the current lead demoted itself on disk and handed the claimant the full roster —
every member's certificate and address (
demoteSelf,router.ts:~449) — and the front door moved. - (b) The former lead's terminals. The demoted lead is pinned to the claimant in that same write, so after its next restart it comes back a peer of the attacker, which then drives the former lead's panes, journal and uploads — the reach this section otherwise reserves to a compromised lead, had from a single compromised peer.
What closes it (§14, ADR 0014): promotion is a confirm on the receiver, not a command from the claimant. The demotion needs the old lead's operator.
newLead()on a leading collie demotes only if a live handover approval — minted on that machine bycollie crew approve-promote <member>, ten minutes, single-use — names the claimant and matches the pinned member's fingerprint, and consumes it in the same committed transition. An unapproved claim is refused (§14), so (a) and (b) both require an operator at the keyboard of the machine being taken from. The approval is not a secret: the claim is already signature-authenticated (§8.6), so consent only has to name who may take over.The peer branch was never the reachable hole, and the transport already closes it. A peer re-pins a new lead only from a claim its listener let through, and a peer's listener pins
ca: [<its lead's certificate>] · requestCert · rejectUnauthorized(§8.1's 2026-08-07 amendment) while its roster holds exactly one member — so the only caller that reaches a peer's adopt branch is the peer's own currently-pinned lead's self-claim, which a re-pin already collapses to a no-op. A promoted new lead is refused at the TLS handshake, not by the route. There is no live peer-re-pin path in v1's topology for a signature to close; the route-level rule a broader topology would need is reserved, not built (§16). The residual, stated plainly. A compromised peer can still spend an approval the operator armed for it inside that ten-minute window, and can still deny service to itself. A compromised lead is unchanged — see the next bullet; nothing here constrains the machine that holds the keys to everything. And--force(§14) strands every peer: a promoted lead a peer does not pin is refused at that peer's handshake, and re-enrollment is the recovery path §14 and §8.4 already name. - (a) Denial of service against the crew. The claim forced a leadership change no operator
consented to: the current lead demoted itself on disk and handed the claimant the full roster —
every member's certificate and address (
-
A compromised lead reaches everything, on every member. This is total, and it is inherent: the lead holds the crew secret and a pinned link to every peer, and its whole job is driving terminals. The lead is a lateral-movement hub by construction. Naming it is the mitigation available at this layer; the operator's mitigation is to make the lead the machine they most trust.
-
A stolen enrollment token buys one enrollment, within 10 minutes, and only from someone who can reach the lead's address. It never buys steady-state traffic — the token authenticates the exchange only. It is single-use: a token spent by an attacker is a token that visibly fails for the operator.
-
An on-path attacker over
http://reads the token and the returned crew secret in the clear, and can self-enroll their own certificate as a member before the honest joiner spends the token (the lead admits on the token alone; F1's fingerprint pin authenticates the lead, not the joiner). This is whycollie joinrefuses anhttp://address unless the operator passes--insecureto own the trusted-hop assumption explicitly (§8.2). -
Someone who reaches a peer's crew port with neither factor learns that something is listening and speaks TLS. No PWA, no version banner, no member id, no distinction between refusal causes (§8.1).
-
Local uid reach.
ARCHITECTURE.md§6 already documents that every uid in the host's network namespace can reach127.0.0.1:$COLLIE_PORT. The crew prefix rides that same port; it adds no new port, and it is harder to use than the existing surface, because it requires two credentials that a local uid does not get for free. The/api/*surface remains the softer target on that machine, and the device gate remains its answer. -
tailscale whoisis an optional extra, never a factor. ACOLLIE_TRUSTED_USER-shaped narrowing on top of a gate that already holds without it (bridge/server.ts:1144-1149is the existing shape). It is never discovery and the model never depends on it.
The peer → lead direction cannot pin at the handshake (§8.1's amendment), and the two requests that
travel it are the most consequential in the protocol: leave removes a member from a roster, and
lead moves the crown (§14). The crew secret is crew-wide, so with it alone any member could
speak for any other. The second factor is therefore re-established over material both sides already
pinned — no new key, no new trust, the same guarantee the handshake gives the other way.
POST /crew/v1/leave, POST /crew/v1/lead and POST /crew/v1/warrant MUST carry a signature.
GET /crew/v1/hello MAY, and does when a verb sends it, so collie crew status and collie reconnect can probe a lead at all. (warrant joined the set on 2026-08-20: §18.12's deposition
travels peer → lead, into a listener that pins nothing inbound.) Nothing else may: the proxy surface
(§5) runs lead → peer over a pinned handshake, and hashing a body to verify a signature there would
pull a streamed upload (§13) into memory on the security path. /crew/v1/enroll cannot — at that
instant nobody has pinned the caller (§8.2).
-
X-Crew-Signature— base64 ECDSA-P256-SHA256 over the canonical string, made with the private key behind the sender's pinned certificate and verified with that certificate's public key. -
X-Crew-Timestamp— epoch milliseconds, decimal. -
The canonical string, exactly:
<METHOD>\n<path>\n<sha256(body) hex>\n<timestamp>Four fields, each closing one substitution: the method so a signed
POSTis not replayable as something else; the path so a body cannot be moved fromleavetolead; the body digest because §14's claim lives in the body; the timestamp so a capture cannot be re-stamped forward. The query string is deliberately absent — no signable route takes one, and signing a value no route reads is a rule that silently stops holding the day one does. The host is absent too: an address is a hint the operator may re-point (§4), and binding a signature to it would make roaming a signature failure. -
Skew: ±5 minutes, both directions. A future timestamp is refused as firmly as a past one — parking a captured request for later is what a future stamp buys.
-
Replay: strictly monotonic per member. A timestamp must be greater than the last one this collie admitted from that member; the floor is persisted (
TrustedMember.signedAt), because a counter that resets on restart is no counter and every membership verb restarts this collie. The floor moves before the request is handled. It is advanced only for the membership routes, which are the state-changing ones — a replayedhellochanges nothing and is bounded by the skew window. -
A failure at any step is the uniform 401 of §8.1 — indistinguishable from a wrong secret, an unpinned certificate, or an unknown member. The signature is checked before the timestamp, so a caller who cannot sign learns nothing about this collie's clock or about what it has already seen.
The warrant is a SECOND signed object under the same key (§18, added 2026-08-20). It reuses these primitives unchanged — base64 ECDSA-P256-SHA256, made with the signer's own key and verified against the certificate the reader already pinned — and it is a different canonical string, never a different algorithm and never a different trust anchor.
The two are kept apart structurally, by a fixed domain tag in the warrant's first field
(collie-crew-warrant-v2), rather than by the field-count disjointness the four-field string above
relies on. That disjointness is real but it degrades with every signed object added, and the key is
genuinely shared: a lead signs hello probes, leave, lead and warrants with one private key. A
tag makes the property structural and costs one string. Retrofitting the request string above is
deliberately NOT proposed — it is deployed, and changing a canonical string is a flag day inside
§7's exact-match window. The tag is for new objects; §16's reserved signed handover should take one if it
is ever built.
The dial attestation — a THIRD signed object, and the lead→peer direction's identity (added 2026-08-20)
Every lead → peer dial carries one, on every route, unconditionally. It is what a two-anchored peer resolves its caller from (§8.1's amendment), and it is a third canonical string rather than a reuse of the four-field one above, for two reasons that are both load-bearing:
- The body cannot be hashed here. A proxied write streams
req.bodystraight through — up to 10 MB of multipart, never buffered on the lead (§13). Signing a digest would mean buffering every upload in the lead's memory on the security path, which is the exact trade the closedMUST carry a signatureset above was drawn to avoid. So this string omits the body, and what it claims is identity, not integrity: body integrity on this hop is the pinned mutual TLS's, and the attacker it closes — a compromised deputy read as the lead — cannot produce the lead's signature over any string at all. - The four-field string names no RECEIVER, and its own text says a broadening of who-pins-whom MUST bind one. A two-anchored peer is that broadening: the lead dials the deputy exactly as it dials every other member, so the deputy legitimately holds lead-signed traffic and could otherwise present it at a sibling peer.
-
X-Crew-Dial— base64 ECDSA-P256-SHA256 over the string below, beside the existingX-Crew-Timestamp(one stamp per request; one request makes one freshness claim). -
The canonical string, exactly — five fields behind the same kind of fixed domain tag the warrant carries:
collie-crew-dial-v2\n<METHOD>\n<path>\n<timestamp>\n<the member being dialled> -
Skew: the same ±5 minutes. The replay FLOOR is deliberately not applied, and that is not an omission: the lead dials several members concurrently within one millisecond, so a monotonic floor here would refuse all but one of every sweep. What bounds a captured dial instead is the receiver field — the only party positioned to capture one is the receiver itself, and the receiver is the only collie it verifies at.
-
A failure at any step is the uniform 401, indistinguishable from every other refusal.
Exactly one route is merged. Everything else is proxied byte-for-byte.
The lead forwards the request to the owning peer and returns the peer's response unmodified:
status, body bytes, content-type, and — critically — etag.
If-None-Matchfrom the phone is passed through to the peer.- Compression is hop-local: the peer hop is
Accept-Encoding: identity, and the peer'scontent-encodingis never re-emitted. The lead asks the peer for uncompressed bytes, so the body it holds is the body it writes out; whatever the runtime compresses and transparently decompresses anyway, it does not strip the stalecontent-encodingfrom the response headers, and re-emitting that header describes bytes that no longer exist. - The lead→phone hop is compressed by the lead itself, on the phone's own
Accept-Encoding, as a stream transform over the identity bytes (CompressionStream("gzip")) — never a buffer, so a 400-turn history is still never held whole. It applies to JSON and text bodies with a body to send; a304/204and a non-compressible type stream through untouched. When it applies, the lead setscontent-encoding: gzipand mergesaccept-encodinginto the peer'sVary(setting it when the peer sent none) — the same negotiation, and the sameVary, a local route already declares (bridge/http-cache.ts). This is a lead-side behaviour only: the peer surface is unchanged. - "Unmodified" means status, decompressed-equivalent body bytes, and the ETag — never a transfer
encoding. The ETag names the identity bytes on both sides of the lead, which is why compressing
this hop cannot invalidate it (ADR 0023).
content-lengthis never copied, and is absent on a compressed hop by construction — a transform cannot know it, and the emitting server frames the bytes it actually writes. - A peer's
304is returned to the phone as a304, with the peer'setagechoed. RFC 7232 §4.1 is satisfied by the peer's own existing code path (bridge/server.ts:467-478). - The ETag on a proxied read therefore means what it has always meant: the peer's assertion about its
own body. The lead adds nothing to it and must not recompute it —
computeEtag()is a hash over a body (bridge/http-cache.ts:16-19), so re-hashing an identical body would be a no-op at best and a silently-different value across a version skew at worst. - The 304-skips-the-transfer win (
bridge/server.ts:460-462) is preserved end to end, which is the entire reason proxying is byte-for-byte rather than parse-and-re-emit.
A blob read is proxied byte for byte, exactly like history (added 2026-09-09). GET /api/blobs/<hash> serves one content-addressed image out of a pi/omp journal's blob store, and that
file sits on the machine whose journal named it — the lead holds no copy and must not fetch one, for
the same reason it does not read a peer's session log. So ?host= forwards the read and the peer's
answer is re-emitted unchanged, ETag included; the ETag here IS the hash, because the store is
content-addressed, which makes a proxied 304 as strong as a local one. The addition is
additive-optional (§7.1): a lead without the route never calls it, and a peer without the route
answers 404 to a lead that does — the same shape a phone gets from a solo collie that holds no
such blob, so the client's rendering of "no image here" covers both without a version check.
CREW_PROTOCOL_VERSION does not move.
The phone's per-pane ETag/body cache is keyed by (host, session, paneId) (§4) so a w1:p1 on one
host can never 304 into another host's mirror — the same failure the session component already
prevents (web/src/lib/api.ts:201-203).
GET /api/snapshot on the lead is assembled from the lead's own state plus each peer's
GET /crew/v1/snapshot. Two changes to SnapshotResponse (bridge/types.ts:164-186):
-
servers?: ServerSummary[]— a new optional field, following theupdate?precedent (bridge/types.ts:182-184), not the always-presentsessionsprecedent (:175-179). See §11 for why the choice is forced.interface ServerSummary { // The wire spelling is `host=`. `?h=` is the SPA route parameter, which // web/src/lib/api.ts translates into `host=` before the request is sent. id: string; // member id (the `host=` value); the lead's own entry is present too name: string; // operator-chosen label isLead: boolean; reachable: boolean; // last poll succeeded protocol: "ok" | "incompatible" | "unknown"; protocolDetail?: string; // the peer's refusal reason, verbatim, when incompatible lastSeenAt: number; // epoch ms, stamped by the LEAD on receipt — never the peer's clock }
reachableis not an invention:SessionSummary.reachablealready models an unreachable member as a rendered state with zeroed counts rather than a failed response (bridge/types.ts:133-145, set atbridge/sessions.ts:171).ServerSummaryis that precedent one level up. -
Every session and every pane is host-tagged.
SessionSummarygainshost: stringand the pane wire shape gainshost: string, so the phone can address what it renders. Absent on a solo snapshot (§11).
Merging is the only place the lead re-serialises. Its ETag over the merged body is the lead's assertion about its own merged view, not any peer's — it necessarily changes when any peer's contribution changes, and it says nothing about whether a given peer's snapshot changed.
v1 is polling. The lead polls each peer's GET /crew/v1/snapshot on its own adaptive interval —
the cadence it already runs (COLLIE_POLL_MS 1500 / COLLIE_POLL_IDLE_MS 12000,
bridge/config.ts:212-213; ARCHITECTURE.md §5). There is no events endpoint on Collie's HTTP API
today and v1 does not add one; events.subscribe is a Herdr socket method (HERDR_API.md) and
never crosses a crew link.
- Peer fetches are concurrent, not serial. N peers must not add N round trips of latency.
- Each peer gets a timeout budget strictly below the lead's poll interval — default
COLLIE_CREW_TIMEOUT_MS = 1200against a 1500 ms poll — so a slow peer can never stall the lead's own snapshot. A missed budget is a stale poll (§10.4), and the verdict that a member is gone is decided by a probe on its own budget, never by this one. - The peer sweep is a part of the existing poll, not a second timer. A solo lead runs no sweep at all (§11).
- A forwarded WRITE is not a poll, and gets its own budget. (added 2026-09-08) The budget above
bounds the sweep, where a slow peer stalls the lead's own snapshot for every phone watching.
A write forwarded to a member (§5, §10.3) is one operator's one request, awaited on that request's
own path, and it asks the member to do something: a launch spawns a process and has the
multiplexer build a tab. Measured in the VM lab on 2026-09-08, that took about 2 s on a zellij
member and straddled the 1200 ms poll budget in roughly two tries out of three — so the phone read
write_outcome_unknownover a tab that had in fact been created, which is the one outcome §10.3 exists to keep rare, produced by arithmetic rather than by a fault. Every write route in §5's table therefore dials onWRITE_BUDGET_MS(bridge/crew/peer-client.ts, 5000 ms), and every forwarded read keeps the poll budget exactly, bootstrap credit and all. The write budget is passed as an explicit deadline, so it spends no bootstrap credit (§10.4) and never enters the sweep's budget accounting. It sits well inside the phone's own 20 s mutation deadline (web/src/lib/api.ts), so the deadline that fires first is still the lead's and the phone still gets §10.3's legible answer rather than a dead socket. Lead-local, never on the wire, so it is not a version-negotiated value (§7.1).
| State | Meaning | Retried on the poll? | Presented as |
|---|---|---|---|
| reachable | Last poll succeeded within budget | yes | live |
| unreachable | Timeout, connection refused, TLS failure, auth failure, or an answer with no protocol header at all (§7) | yes | last-good state, stale, with lastSeenAt — and split for the operator into Reconnecting and Attention, below |
| incompatible | X-Crew-Protocol names a version this build cannot speak (§7) |
no (probed on a slow backoff) | last-good state, incompatible, with the peer's reason |
| conflicted (added 2026-08-20) | The member answered §18.10's named 409: it follows a different lead |
no — there is nothing useful to fetch from a machine that belongs to someone else's view of the crew | last-good state, conflicted, naming the lead it follows and that lead's warrant generation |
-
Unreachable is a value, never an error. A down, slow, skewed or unauthenticated peer never produces a 5xx for the whole crew and never produces a blank phone. The lead's snapshot always answers 200 with whatever it has.
-
A peer's sessions never vanish. They are listed from the last-good snapshot, marked stale with an age derived from
lastSeenAt. A triage list that flickers is worse than one that is honestly stale — panes must not disappear and reappear between polls. -
Freshness is the lead's receipt time. A peer's clock is never trusted;
lastSeenAtis stamped when the response lands, which is also why no timestamp header rides the response (§6). -
Every landed call is a receipt — the sweep is the floor, not the only source. A proxied read or write (§5, §9.1) that the peer answered refreshes
lastSeenAtexactly as a sweep does: same clock, same meaning. This matters because the sweep rides the lead's own adaptive interval and relaxes toCOLLIE_POLL_IDLE_MS(12 s), while a phone watching that peer's pane polls at 1.5 s — so a receipt only the sweep refreshed made a perfectly healthy peer read stale for most of every sweep. The fold is successes only, for a member already believedreachable, and monotone: how a failure is classified stays the sweep's and the probe's business (§10.4), so there is still one path to the word "unreachable". Ahelloprobe remains the exception that stamps nothing (§10.4) — it carries no snapshot. (bridge/crew/registry.ts→recordExchange.) -
Presented-stale threshold: a member is rendered stale once its
lastSeenAtis older than3 × pollMsor 15 s, whichever comes first. Below that, a single missed poll is invisible — the same tolerance the herd link already gets.pollMsis the phone's cadence, and the bullet above is what makes measuring the lead's receipt against it sound. -
Stale is not unreachable, and the UI may not spell it so. The table's "Presented as" column describes the content (last-good, labelled), not a verdict on the machine:
reachable: truebeside an old receipt is a normal, common state, and writes to such a member are not refused (§10.3 refuses on the lead's boolean alone). A surface may therefore only print "unreachable", or claim that replies and keys are refused, when that boolean is false. -
A missing protocol header is unreachable, an unreadable one is incompatible. (added 2026-09-08) The two look alike on the wire and they are opposite findings: one says the lead learned nothing, the other says the lead learned it cannot speak to this member. Filing the first as the second puts a peer that is merely coming back on the 30/120/600 s ladder; that mechanism was reproduced on the dev crew on 2026-09-08, and it is a candidate cause of the 2026-09-07 blind window rather than a proven one. §7 holds the rule and its patience window.
-
unreachableis presented as two words, and neither is a fifth wire state. (added 2026-09-08) Four causes shared one word, and the operator's next move is not the same in all four: a timeout clears itself, a rotated secret does not. So the lead carries its own reading beside the health value, as an additive, optional field (§7.1) namedlinkState, onServerSummaryand on the crew page's member row:linkStateThe lead's claim Presented as The operator reconnectingIt is retrying, and it is inside its budget. A timeout, a refused connection, a member part way through a restart. the quiet reading, styled like stale, never red does nothing attentionRe-dialling will not fix this. Auth refused (§8.5), a protocol this build cannot speak (§7), a member in another crew (§18.10), a member that said no (§14.3), or a spent retry budget. the loud reading must go and look healthdoes not change, andCREW_PROTOCOL_VERSIONstays1. An unreachable member is stillunreachableon the wire, so a released phone renders exactly what it renders today.- The field is OMITTED when there is nothing to say — every reachable member, and every member
of a lead older than the field. An absent value beside a member that is not reachable therefore
means "this lead offers no distinction", and the surface prints the single old word rather than
inventing one. Absent is never read as
reconnecting. - The budget is a count of silent sweeps, and it is the same count that fails a run's leg (§20), so one lead holds one idea of how much silence is too much. It is lead-local, never on the wire, and moving it needs no protocol change.
- Red for a condition that clears on the next poll is the mistake this split exists to avoid: a screen that shouts about a correct outcome teaches the operator to distrust it.
-
conflictedis none of the other three, and a surface may not spell it as one. It is notunreachable— the member answered, and answered precisely. It is notincompatible— this build reads that member's protocol perfectly well; the two merely share a409, told apart by the body'scode(§18.10). And it is not a refusal of an action: it refuses the caller's whole premise about who leads. Rendering it as any of the three sends the operator to the wrong remedy — a cable, a build, or a verb — when the real one is that the crew has moved on.
A write to an unreachable or incompatible peer fails immediately and legibly. There is no queue and no automatic retry.
This is ADR 0010's reasoning carried across a lossier link. A send whose outcome is unknown must be surfaced, never re-sent: the bytes may already be in the terminal, and a retry types them twice. Concretely:
- A write to a member the lead currently believes unreachable is refused before it is attempted,
with a message naming the member and its
lastSeenAt. - A write that is attempted and then times out is reported as unknown outcome — explicitly not as a failure, and explicitly not retried. The operator re-reads the pane and decides.
- A write to an incompatible member is refused with the protocol-mismatch reason.
- Nothing is buffered for later delivery. A crew is not a message queue, and a pane that has moved on is exactly why (the same reasoning that forbids a key queue outliving its dock, ADR 0005).
On the wire (what the phone renders on — bridge/crew/forward.ts): every lead-generated refusal
is JSON with {ok: false, code, error, host} and a distinct status — host_unreachable (503),
host_incompatible (503), write_outcome_unknown (504), upload_too_large (413),
route_not_federated (501, for a route outside §5's table). Never a bare 500,
and never a silent success. A peer's own answer is never given one of these: it is passed through
as itself (§9.1), including its 403 when the peer's write gate refuses.
A healthy peer behind a Tailscale DERP relay (≈350 ms RTT, TLS handshake measured at 1.9 s) read
unreachable forever. The arithmetic was at fault, not the peer, and the measurement says why:
Bun's fetch does reuse a pinned-TLS connection (five sequential dials, one TCP accept), but an
aborted attempt leaves none behind — so when the cold handshake alone costs more than the whole
per-request budget, every attempt aborts mid-handshake and the link never bootstraps. One patient
call breaks the deadlock and every strict-budget request after it rides the warm connection.
So the two questions are budgeted separately:
- Per-poll data requests keep the strict clamped budget of §10.1, and a miss keeps meaning
stale this poll — never peer gone. (Amended by §10.5: a cold link's first data request is
owed one patient attempt, because the deadlock above is not exclusive to
hello.) - The reachability verdict comes from a
helloprobe with its own budget —COLLIE_CREW_HELLO_TIMEOUT_MS, default 5000 ms, not clamped by the poll fraction (clamping it would restore the deadlock) and floored at the data budget. It runs off the poll's hot path: the lead starts one, never awaited, for a sweep that died on its own clock, at most one per member in flight, and arms no timer — §10.1's "no second timer" is untouched. Its answer is also what warms the connection the next data request rides.
A member that answers the probe but misses the data budget is reachable with a slow-link reason,
beside the lastSeenAt of its last real snapshot — the honest rendering of "the machine is there,
its data is old". A probe never stamps lastSeenAt: a hello carries no snapshot. A failure that
is not a timeout (refused, DNS, TLS) is never re-probed patiently — those are answers from the
world, and asking them again slowly is only slower.
§10.4 budgeted the verdict out of the deadlock and left the data path in it. Measured on the same
real DERP-relayed link: hello cold 1.86 s → 200, snapshot cold including the handshake
1.22 s → 200, snapshot warm 0.12 s, pane read warm 0.11 s. Every data request carried
the strict ~1200 ms budget, so a cold one aborted mid-handshake, pooled nothing, and left the next
one cold as well — the peer read unreachable and every pane read answered 503 host_unreachable
after exactly one budget, forever. Warm budgets were never the problem; only bootstrap was.
So a data request gets one patient attempt per cold link, bounded so it can never become the
steady-state budget (takeDataBudget, bridge/crew/peer-client.ts):
| link | first data request | after |
|---|---|---|
| never dialled, or dialled and torn down after a success | the patient budget of §10.4, once | strict |
| warm (a dial reached the far side, nothing failed since) | strict | strict |
| cold with its one credit already spent | strict | strict |
The credit is spent at issue, so concurrent requests and later polls never stack patient dials:
at most one is in flight per link. A host that is genuinely gone therefore still fails in one strict
budget per poll. Warmth is remembered per address — a member that moved (collie reconnect) is a
different connection and correctly starts cold — is never persisted, and decides a timeout and never
a verdict; losing it costs one patient dial.
Every reachability finding asks both questions. hello alone was a lie by omission: it runs on
the patient budget while every real read runs on the strict one, so collie crew status printed
reachable and collie doctor printed member-reach ✓ over a crew that was 503ing every pane. Both
verbs now send one real GET /crew/v1/snapshot after the probe, on the same client (so it rides the
warmed connection, which is the bridge's steady state), and report each half with its timing. A
member that answers and then starves is its own finding, with its own remedy: the address is right,
the budget is not.
The clamp of §10.1 is not silent. COLLIE_CREW_TIMEOUT_MS above 0.8 × COLLIE_POLL_MS is still
clamped — that arithmetic is what stops a slow peer stalling the lead — but the bridge warns at boot
and crew status prints the same line, naming COLLIE_POLL_MS as the other half an operator must
raise. COLLIE_CREW_TIMEOUT_MS=3000 at the default poll buys exactly nothing, and used to say so
nowhere.
The whole of §10 in one tick. The two budgets are the thing to read off it: the data budget decides this poll, the patient probe decides the verdict.
sequenceDiagram
autonumber
participant L as lead
participant A as peer A
participant B as peer B
Note over L: poll tick, the lead's own adaptive interval, no second timer
par one budget each, concurrent
L->>A: GET /crew/v1/snapshot
A-->>L: 200 snapshot
Note over L: reachable, lastSeenAt stamped on the lead's clock
and
L->>B: GET /crew/v1/snapshot
B--xL: data budget missed
Note over L: stale this poll, last-good body kept, never blank
end
L->>B: GET /crew/v1/hello, off-tick, patient budget, never awaited
alt hello answers
B-->>L: 200 hello
Note over L: reachable with a slow-link reason, connection now warm
else hello times out
Note over L: unreachable, retried every poll forever
end
A protocol mismatch is the one answer that leaves this loop: it is incompatible, not unreachable,
and it backs off instead of being retried on the poll (§7, §10.2).
With zero peers enrolled, Collie's observable behaviour is byte-for-byte what it is today. This is a gate, not an aspiration — M2/05 lands the characterization tests that enforce it before any federation code exists to break it.
| Surface | Solo behaviour | Decided at |
|---|---|---|
| Routes served to a browser | unchanged; zero routes added, zero status codes changed | bridge/server.ts:165-390 |
/crew/v1/* |
not routed at all — no crew prefix is registered with zero peers | §5 |
| Snapshot bytes | unchanged — servers is omitted, and no host field is added to sessions or panes |
bridge/types.ts:164-186 |
| Snapshot ETag | unchanged. Follows from the row above: no added field, no shifted hash | bridge/http-cache.ts:16-19 |
?session= with no param |
primary session, bit-identical | bridge/sessions.ts:154-157 |
?h= |
never emitted by the client, never present in a URL | web/src/lib/session.ts:28-31 |
| Notification tags | unchanged — the primary keeps the bare collie:herd |
bridge/sessions.ts:33-35 |
| Push payload | unchanged — no host field, mirroring how session is stamped only for non-primary |
bridge/push.ts:124-131 |
| Poll cadence | unchanged — no second timer, no peer sweep, same idle relaxation | bridge/event-poker.ts, bridge/config.ts:212-213 |
| Audit line bytes | unchanged — host is omitted, not null, exactly as session/device are today |
bridge/audit.ts:55-61 |
| Files written | exactly today's set: uploads/, audit.log, push-subscriptions.json, snooze.json, notify-prefs.json, activity.json, update-state.json. No key, no certificate, no trust store, no roster. |
bridge/server.ts:1075, bridge/audit.ts:65, bridge/push.ts:86, bridge/snooze.ts:19, bridge/notify-prefs.ts:45, bridge/activity.ts:100, bridge/update.ts:147 |
| Ports opened | exactly one, loopback, as today. The standby door's second listener (§18.15) is bound only when COLLIE_STANDBY_PORT is set and a trust store exists, which a solo instance has neither of |
bridge/config.ts:210-211, bridge/crew/standby.ts |
Why servers is optional-and-absent rather than always-present. An always-present field — even a
single-entry one, the shape sessions chose — changes every solo snapshot body, and therefore every
solo snapshot ETag, exactly once. That is a real cost (one forced refetch for every solo user on the
release) paid for a uniformity nothing needs, and it contradicts byte-for-byte. update? is the
precedent that fits: absent means "no crew", which is precisely true. Solo mints nothing and emits
nothing.
Where the gate lives. bridge/solo-baseline.test.ts (+ goldens under
bridge/fixtures/solo-baseline/) and web/src/lib/solo-baseline.test.ts. Both were landed in
1.0.0-alpha.1, before any federation code existed — written afterwards they would only re-record
whatever the new code does. They pin the table above in two layers: an exhaustive
Record<keyof T, true> per wire type (so adding servers?:/host?: fails bun run typecheck at the
line it was added, and satisfies SnapshotResponse in server.ts closes the loop), plus a
byte-compared golden body and its ETag. A failure there is not a stale golden — it is a solo
instance's behaviour moving. Regenerating is a deliberate act
(COLLIE_REGEN_SOLO_BASELINE=1 bun test bridge/solo-baseline.test.ts) and must be called out in
the PR description with the reason and the row it renegotiates.
What the unit baseline cannot reach. Collie deliberately unit-tests only pure/injectable modules —
anything needing Bun.serve is out of bun test's reach (CLAUDE.md). So four claims above are pinned
only indirectly (route literals, config defaults and payload shapes read out of the source) and need
the M4 integration harness to be asserted for real: status codes unchanged per route, the actual
bound port count, the absence of a second timer / peer sweep at runtime, and the live push
payload for a primary-session alert. Those four are the integration harness's charter; everything
else in the table is covered by the unit baseline today.
Status 2026-08-07 — the harness landed (
bridge/crew/harness.test.ts); three of the four rows are now measured.
- Status codes per route — measured on a live solo instance:
/api/snapshot,/api/configand a real pane read answer today's codes, and/crew/v1/*is indistinguishable from an arbitrary unknown path (same status, no version banner). Asserted as indistinguishability rather than as a literal404, because the code depends on whether a frontend build is present and the promise does not.- Bound port count — measured: exactly one, and its neighbour is closed.
- No second timer — measured indirectly, and the indirection is the honest form of the claim: the lead's call rate to its own Herdr is recorded while solo and re-measured once it leads a crew, and must not move. A lead that had armed a sweep timer of its own would poll on two clocks.
- Live push payload — still out of reach. It needs VAPID keys, a real subscription and a web push endpoint; the harness has none, and
web-pushis an optional dependency. Its shape stays pinned bypush.test.tsat the unit layer. Closing it properly is M5/M6's, and it needs a loopback push receiver, not a bigger crew harness.
A write reaches a peer only through the lead, and the peer's own audit log is the record of what happened on the peer's terminals.
- The lead forwards
X-Crew-Device: <device-id>— the operator's device identity as the lead resolved it viadeviceAuth()(bridge/server.ts:1216-1223). Absent when the lead's device gate is off, matching how the field is omitted rather than nulled today (bridge/audit.ts:55-61). - The header is trusted because the crew link authenticated it, not because it was sent. It is
meaningful only on an admitted crew request (§8.1) — exactly the trust basis
COLLIE_DEVICE_HEADERalready rests on for a co-located proxy (bridge/server.ts:1216-1223). - The peer writes the entry to its own
<stateDir>/audit.log(bridge/audit.ts:64-67), with the device carried through asdeviceand a newvia: "crew"marker plus the originating member id, so a crew-originated action is identifiable in the peer's log without ambiguity. The peer's operator, reading their own log, sees who did it and from where. - The lead also records the forward in its own log — one line,
actionunchanged, plus the targethost. The two logs are independent records of the same event, which is the point: neither machine depends on the other's disk to answer "what happened here". - A peer is never asked to trust the lead's authorisation decision in place of its own. The peer applies its own write-level checks to a crew request; the lead's gate does not stand in for them.
The path is phone → lead → owning peer's disk.
- The lead forwards the multipart body to
POST /crew/v1/pane/:id/upload; the peer runs the existing handler and writes into its own<stateDir>/uploadswith the existing 0700 discipline (bridge/server.ts:1075-1090). - The returned
pathis peer-local and absolute on the peer's filesystem. That is the requirement, not a leak: the path is typed at an agent running on that machine, and Herdr on that machine must be able to open it. A path on the lead's disk would be dead on arrival. - The lead never stores the file and never rewrites the path.
- Upload sweeping stays per-machine (
bridge/uploads.ts, driven frombridge/index.ts:195-202) — the peer expires its own files. - The size cap is per member, and the peer's is the one that decides. It is the host's own
COLLIE_MAX_UPLOAD_MB(cfg.maxUploadBytes), not a protocol constant. The lead runs aContent-Lengthpre-check against its own number before forwarding, so a phone on cellular does not spend its uplink on a body that was always going to be refused; the peer then re-checks the decoded size against its own when the bytes land. The pre-check can only refuse early, never permit — so two members on two different numbers is legal, and merely confusing. Keep a crew on one number. - What may be written is the peer's decision too. The accepted types are the peer's shipped list
plus its own
COLLIE_UPLOAD_EXTRA_TYPES(bridge/uploads.ts), and the lead does not filter by type at all./api/config'suploadblock reports the answering host's limits, so a phone on?h=peerreads the LEAD's block and may offer a type the peer refuses. That refusal arrives as the peer's ownupload.bad_type, which is the correct place for it.
collie promote is a deliberate operator action, run on the peer that is to become lead.
Transparent failover is a non-goal.
Promotion is a confirm on the receiver, not a command from the claimant (amended 2026-08-11, closes F2 — ADR 0014). A signature proves which member is speaking (§8.6); it cannot prove that an operator willed it. So the crown moves in two steps on two machines, and each step is refused without the one before it:
- on the current lead,
collie crew approve-promote <member-id>— a ten-minute, single-use consent naming who may take over, which restarts the lead so the running process holds the approval (§14.1); - on the peer,
collie promoteinside that window — which demotes the lead and takes its roster.
Touching both machines is the design, not friction: consent run on the lead is what proves the operator controls the machine that is about to lose its terminals, its roster and its front door.
Mints a pending handover approval and restarts the lead so the running process holds it. The approval is persisted in the trust store beside the crew's other state:
/** The operator's consent, on the lead, for ONE named member to take the crown (§14). */
interface PendingHandover {
readonly memberId: string; // who may take over. The whole content of the consent.
readonly createdAt: number;
readonly expiresAt: number; // createdAt + 10 minutes
}It sits at the top level of the trust store, sibling to invites — pendingHandover?: PendingHandover | null. parseTrustStore builds its result from an explicit field whitelist
(trust-store.ts), so the field must be added in both the validator and the returned result
literal, or the approval is silently dropped on every read and gate 1 is permanently closed. Absent ⇒
no live approval ⇒ refuse: the fail-closed reading holds through that parser.
- The mint restarts the lead, and this is load-bearing, not incidental. A collie reads
its trust store at most once per process (
trust-store.ts, theloadedlatch), so an approval a CLI writes to the store on disk is invisible to the already-running collie — the promotion would then refuse forever.approve-promotetherefore mints and restarts (applyLocally, exactly as every other membership verb does — "a membership change takes effect through the restart every membership verb performs", §8.1's 2026-08-07 amendment), so the process that later fields the claim has already read the approval. The honest cost: the restart drops the lead's live crew links and the phone's connection for a moment — but it happens at approve-time, before the operator walks to the peer, so thepromoteitself runs against a lead that already holds the consent. It also closes the rebuilt-but-not-restarted skew trap for this verb, because the restart re-execs the current binary. - Ten minutes, the same window and the same reasoning as an enrollment token (§8.2,
INVITE_TTL_MS): long enough to walk to the other machine, short enough that an armed approval is not a standing capability. - Single-use. Consumed in the same committed transition as the demotion — never before it, so a demotion that fails to persist does not burn the consent, and never after, so one approval cannot demote twice.
- At most one live at a time. Minting replaces any prior. A store is not a queue, and two live approvals would mean the operator had armed a race they cannot observe.
- Swept lazily, exactly as invites are: an expired approval is read as absent, and the next write drops it.
- It is not a secret and carries no token. The claim is already signature-authenticated against a pinned certificate (§8.6), so consent only has to name who may take over. No new secret material crosses the wire, and a leaked trust store yields nothing spendable from this field.
collie crew approve-promote --cancelclears the live approval — and, like the mint, restarts, so the collie forgets it.--cancelparses as a bare flag (bareFlags: ["cancel"]) so it consumes no following token; with nothing armed it exits cleanly (EXIT.OK, "nothing was armed"). TTL and replacement are the only other ways an approval ends; the operator who armed it and changed their mind should not have to wait the window out.- Validation. It refuses when this machine is not leading (
EXIT.STATE) and refuses a member id not in the current roster (EXIT.STATE) — an approval naming nobody the lead pins is a typo, not a consent. On success it prints who is approved, the ten-minute window, and the exact next step (now runcollie promoteon<member>within 10 minutes). It registers inCREW_SUBCOMMANDSand the help block. collie crew statusshows a live approval as its own line —handover approved: <member-id> — expires in Nm— in the same spirit as §8.4's per-member secret column; a swept (expired) approval reads as absent. On a peer, where no approval can exist, it shows nothing.- Absent field = no live approval, never a default-open reading. A trust store written before this field existed has no approval, so an unamended lead upgrades into refusing rather than accepting.
Audited as crew.handover.approve and crew.handover.cancel; the consumption is recorded inside the
existing crew.demote line, which now names the approval it spent.
POST /crew/v1/lead is still one route with two roles (§5). Only the lead role gains a
requirement; the peer role is unchanged.
| Recipient | Was | Now also requires |
|---|---|---|
the old lead (isLeading) |
a §8.6-signed self-claim | a live approval naming the claimant, whose fingerprint equals the pinned member's (§14.1), consumed with the demotion |
| a peer | a §8.6-signed self-claim from the lead it pins | unchanged — a peer adopts only its own pinned lead's self-claim, and the transport already refuses anyone else |
Both requirements are in addition to everything §8.1/§8.6 already demand — the two factors, the signature, and the rule that a member may only claim leadership for itself. Neither replaces them.
Consent must name the certificate, not just the member id. newLead today checks only that the
claimed member id is the admitted one, and then pins the claim's certificate — so an approved
member could pin any certificate under their id, including a key they do not hold, which the old lead
would then trust. The demotion therefore additionally requires claim.fingerprint to equal the
fingerprint of the admitted, pinned member (from.fingerprint). Because parseRosterEntry
already enforces fingerprint === sha256(certPem), matching the fingerprint binds the certificate:
"consent names who may take over" is only true if the key that takes over is the one already pinned.
The authorization has an error channel of its own. The pure demotion transition returns a
discriminated refusal, not a bare null: not-leading (the receiver is a peer, not the lead of
this crew) maps to the existing 400, and not-approved (no live approval names this claimant, or
its fingerprint does not match) maps to §14.3's 403. A bare null — today's "no change" — can only
be the 400, so the honest 403 needs the discriminant. Reading the approval and demoting are one
committed transition: one next, the approval consumed in the same write as the role flip, one
audit line naming the approval spent. Because the check runs inside the single serialised
TrustStore.update write, there is no pre-read/expiry race — and the refusal path must not add a
further store write, since the replay-floor commit for membership routes already wrote before the
handler ran (§8.6); gate 1 must not compound it.
The peer branch is untouched. A peer still adopts only a self-claim from the lead it currently pins, and its listener refuses any other caller at the TLS handshake (§8.1). A crew of three or more machines re-joins its non-old-lead peers against the new lead (§14.5); there is no peer-side attestation to carry, and none is needed while a peer pins exactly one lead. The route-level rule a broader topology would want is reserved (§16).
An unapproved claim, at the lead — 403, and free to say why: the caller passed both factors and
§8.6, so §8.1's uniform-401 rule does not apply (it exists to tell an unauthenticated caller
nothing). This is the post-admission honest-error family, one status up from badRequest because the
caller is admitted but not permitted — §5's "admitted and allowed to do this are different
questions", answered on the wire. It carries a machine-readable code.
{ "error": "this lead has not approved \"nas\" to take over — run `collie crew approve-promote nas` here, then re-run `collie promote` on that machine within 10 minutes",
"code": "handover_not_approved" }An approval that names a different member — or one whose fingerprint does not match the pinned member's — produces the byte-identical response. The claimant is not told who is approved; that is the operator's business on the lead, not a fact the wire owes an unsuccessful claimant.
collie promote must surface this refusal honestly, and must not point at --force. The
peer-client today collapses every non-2xx that is not 401/409 into unreachable and discards the
body, after which promote prints "the current lead did not answer … re-run with --force" —
aiming the operator at the destructive remedy for what is actually an un-approved promotion. So the
client gains a distinct refused outcome that carries the body: on a 403 bearing a code,
promote surfaces the lead's message verbatim, exits EXIT.REFUSED, and suppresses the
--force suggestion. --force is correct only for genuine unreachability, never for a refusal.
A successful demotion answers with the roster it always did — { demoted, roster }, unchanged
from before this amendment:
{ "demoted": "desk",
"roster": [
{ "memberId": "nas", "fingerprint": "9f2c…", "certPem": "-----BEGIN CERTIFICATE-----\n…", "address": "https://nas.example:8787" }
] }The new lead pins the roster it is handed and starts leading. No signed object travels with the response: the demotion is authorised on the lead by the lead's operator, and in v1 there is no third machine that must be convinced of it — a peer learns a new lead only by re-joining (§14.5, §16).
--force is for an old lead the operator knows is gone. Promoting past a reachable lead is
refused — that would give the crew two front doors and two rosters — so --force is the operator
explicitly accepting the risk for a machine they know is down. It sweeps nobody: a peer pins its
current lead at the handshake (§8.1's 2026-08-07 amendment), so a promoted lead a peer does not yet
pin is refused at that peer's TLS handshake regardless. collie promote --force therefore skips the
peer sweep entirely and prints the re-join for every member instead. (In the shipped code the forced
path already carries an empty roster, so the sweep had nobody to dial — the promise now matches.
2026-08-12: the unforced path's sweep is gone too, for the reason above — it could never land, and a
column of ✗ lines misread as a partial failure is worse than the plain instruction; §14.5 states the
re-enrollment rule both paths now print.)
This is accepted rather than worked around. §15 already declares transparent failover a non-goal, and
§8.4 imposes the identical rule on a peer that misses a rotation. Re-enrollment is the recovery path
for every remaining member: collie join against the new lead with a fresh token.
--force still leaves the old lead believing it leads, so it must be collie leave-d or re-join-ed
before it is ever powered back on into the crew.
- The crew identity, the crew secret and existing pinned certificates are reused — promotion is a role change, not a re-enrollment. What changes is which member holds the front door and which address the others dial.
- The claim is signature-authenticated (added 2026-08-07).
POST /crew/v1/leadcarries §8.6's signature, made with the key behind the claimant's pinned certificate, over a canonical string that includes the body — so the claim and the certificate travelling with it are under the signature. A member may still only claim leadership for itself (the claimed id must be the admitted one), and the two rules are complementary: the signature proves who is speaking, the id check stops them nominating a third party. Without this, a crew-wide secret plus a lead whose front door terminates TLS (§8.1) would let any member move the crown to any other. - Only the old lead is reachable by the promotion itself (added 2026-08-07). Every other peer pins its current lead at the handshake, so the new lead's connection is refused until that peer re-joins. With two members this changes nothing. With three or more, the peers that are not the old lead fall under the re-enrollment rule below. This handshake refusal is the only thing between a promoted lead and a peer in v1 — there is no application-layer peer-side rule, because a peer pins exactly one lead and a route-level adoption rule is needed only once that stops being true (reserved — §16, ADR 0014).
- Every remaining peer re-enrolls; the promotion updates none of them (corrected 2026-08-12 — an
earlier draft said reachable peers are updated by the promotion itself, which the previous bullet's
handshake rule makes impossible: each peer pins its current lead's certificate, so the new lead's
dial is refused at TLS before any application code runs, reachable or not).
collie joinagainst the new lead with a fresh token, for every peer that is not the old lead — the same rule rotation uses (§8.4), for the same reason. With two members there are no such peers and nothing to do. - The demoted lead's roster entry names an address the demotion itself retires (added 2026-08-12).
The new lead carries the old lead into its roster at the address it always dialed it at — the old
lead's front door, which the hand-over's own next step (
collie unservethere) tears down. And a machine that led from behind a front door typically binds loopback, so it has no dialable crew listener until its operator setsCOLLIE_HOSTand restarts (§4 — an address is a fact about the dialler's network; nothing in the protocol can conjure one for a machine that never had it). The repair is two existing verbs, andpromoteMUST print them as steps: on the demoted machine, setCOLLIE_HOSTto an address the new lead can dial, thencollie restart(the same.envchange every peer makes — §8.2); on the new lead,collie reconnect <old-lead> <host:port>. Until thencrew statusandcollie doctorhere show that member unreachable, anddoctorthere names the loopback bind (both by design — the state is visible, not silent). No wire field is added for this: a bind is not a name, so nothing the demotion reply could carry is trustworthy as a dialling address (§7.1's class rule would permit the field; §4's addressing rule is why it would be wrong). - The phone re-points manually. The front-door URL is bound to a node; nothing rewrites a bookmark. This is stated as an operator step, not hidden.
- The old lead's front door is torn down by the old lead. Collie tears down only a mapping its own
ownership record matches (ADR 0001), and that record lives
beside the CLI on that machine — no process publishes or unpublishes a tunnel on another operator's
say-so.
promoteprints the exact command (collie unserve) to run there; it cannot run it. - Nothing else follows the crown. Push subscriptions, the audit log, outstanding notification tags
and activity ledgers are host-local by rule (§2) and stay on the old lead. The phone re-subscribes
against the new one.
promoteenumerates this in its own output.
The change is additive: one optional trust-store field, no new wire object.
CREW_PROTOCOL_VERSIONstays1. The approval is a body/field addition, not a new route or a changed shape, and §7's window is exact-match (admission.ts) — bumping it would take every route down between differently-updated members in order to close a hole in one, trading a denial-of-service for the escalation.TRUST_STORE_VERSIONstays1. The field is read as optional, andparseTrustStorerefuses an unknown store version — so bumping it would make an updated collie reject its own pre-amendment store. Do not bump it.- Absent means closed, never open. No approval field ⇒ no live approval ⇒ an unapproved claim is refused. A pre-spec store upgrades into refusing.
- Updating the lead closes F2 for the whole crew. The gate lives entirely on the machine being
demoted, so a crew realizes the fix the moment its lead is updated. A pre-spec lead
(≤
1.0.0-alpha.9) simply accepts the unattested claim as it does today; the improvement is realized once that lead is updated, and no peer needs the new build for it to hold. - Migration is "update the lead". No state change, no re-enrollment, no rotation.
- The class rule this amendment is an instance of — additive-optional, absent-means-closed, and build skew never refusing — is §7.1.
- No claim-then-confirm. There is no pending-inbound claim on the lead, no polling by the peer, and no waiting state anywhere. The operator's second machine is what carries the consent.
- No quorum. No countersigned roster generation, no threshold of members agreeing. This protocol has two roles and frequently two members (ADR 0014's alternatives).
- No revocation channel for an approval beyond TTL, replacement and
--cancel. Nothing is sent to anybody when one is armed or cleared; it is local state on the lead. - No change to who may INITIATE a promotion. Any enrolled member may still ask. Only execution is gated — which is what makes the refusal a legible operator error rather than a permission model.
Note, added 2026-08-07 — the demoted machine needs a restart, and
promotesays so first.The old lead adopts its demotion on disk, in the request it answers. Its process does not change: it keeps the lead-mode listener it bound at boot — which, under §8.1's amendment, pins nothing — and its front door, until something restarts it. So
promotenow printscollie restart, thencollie unserve, for that machine, in that order:restartrunsstart, which publishes, so tearing the front door down first would race the thing that re-publishes it (the same orderingcollie joinuses). Locally, the demoted machine says it too — in its own log, and incollie crew status, which reports it as apeeron disk and aleadin memory (§8.2's note).The demoted collie does not restart itself. Exiting so a supervisor restarts it would work under systemd (
Restart=on-failure) and launchd (KeepAlive/SuccessfulExit=false) — and would take the machine's Collie off the air entirely on the unsupervised tier, which nothing restarts and which is reached exactly where an operator is least present (a Mac whosegui/<uid>bootstrap refused). A collie is launched identically on all three tiers and cannot tell which one it is under; supervision is the CLI's knowledge. A demotion is not a licence to end a process that may not come back, so the honest v1 answer is the operator's restart, named in three places.
stateDiagram-v2
[*] --> leading
leading --> deputised: collie crew deputy nas
note right of deputised
warrant minted, pushed, anchored
at each peer's restart (18.5)
still just a healthy crew
end note
deputised --> leading: collie crew deputy --revoke
leading --> lead_down: the lead process or its machine dies
deputised --> lead_down: the lead process or its machine dies
note right of lead_down
phone: amber at 4s, red at 15s
peers: unaffected and unaware
agents on peers keep running
end note
lead_down --> recovering: the operator restarts the lead
recovering --> leading: boot reads crew-trust.json, first sweep repopulates
lead_down --> armed: deputy sees COLLIE_STANDBY_ARM_MS of silence
armed --> lead_down: the lead calls again, instantly disarmed
armed --> taken_over: operator taps take over, lead silent, no peer contradicts
armed --> lead_down: a peer answers lead_is_alive, nothing changed
taken_over --> leading: the deputy is the lead now, warrant spent
note right of taken_over
peers re-pinned, pairing adopted
the deputy restarts into lead mode
name a new deputy afterwards
end note
lead_down --> deposed: the old machine boots and meets the warrant
deposed --> healed: self-heal on material both sides already held
healed --> leading: an ordinary peer of the new lead
lead_down --> promoted: no deputy, and the lead is gone for good
promoted --> [*]
note right of promoted
collie promote --force
every remaining peer must re-join
the old machine still believes it leads
end note
Nobody elects anything, and no peer notices. There is no peer-side timer in the protocol — a peer answers when dialled and is silent otherwise — so a dead lead is, from a peer's side, a lead that has not called lately. Agents on peers keep running; only the operator's window onto them closes. The phone is the only party that reacts, on its own tier-1 connection model: amber at 4 s, red at 15 s.
The lead role is the trust file. crew-trust.json is read once per process, at boot
(§14.1), and the same machine resumes leading simply because that file still names it so. Nothing is
negotiated on the way back: peers re-admit the returning lead on the pinned certificate, the crew
secret and §8.6's signature, all of which are on disk on both sides. What a restart loses is only
in-memory — last-good snapshots and the health registry (§7.1) — so a restart is indistinguishable
from a network blip except for one poll cycle in which peers render from an empty cache rather than a
stale one.
Keeping the lead alive is the operator's job, by design. Collie never restarts itself — except at
the instant a takeover commits (§18.16) — for the reason §14.7's closing note gives: a collie cannot
tell which supervision tier it is under, so exiting to be revived is a bet it may lose. Put the lead
and the deputy under systemd --user (or launchd) and let that supervise them.
If the machine is not coming back, the deputy is the answer. A crew that named one (§18.13) is
recovered from a phone: the door arms itself on silence, the operator's tap spends the warrant, and
the exchange refuses if the lead answers or any peer says it was dialled recently (§18.15, §18.16).
Nothing here is automatic — silence arms the surface and never authorises the action
(ADR 0026). When the old machine returns it meets the
warrant, deposes itself and heals to peer on material both sides already held, with no operator step
(§18.12). Two things it does not do for you: the warrant is spent, so name a new deputy; and the
old machine's front door is its own operator's to tear down.
Without a deputy the floor is unchanged: §14.4's collie promote --force — the path that skips
the consent the old lead can no longer give, and pays for it by stranding every other peer. Read that
section before running it; its costs are not summarised here.
- Overlay-network integration of any kind. No Tailscale / NetBird / ZeroTier enumeration, discovery or membership sync — ever. An address and a token is the whole contract. This extends ADR 0001: Collie manages one front door per crew, the lead's, and peers manage none.
- A multiplexer's own machine linking. Whatever a multiplexer can reach on another box is
not a crew transport, and its adapter reports only panes whose terminal runs on its own machine.
This is the same refusal as the overlay-network one, one layer down: the map of machines is
Collie's, a crew member is a full collie, and a pane's journal, uploads and audit log sit on the
machine that runs it
(ADR 0036). Such a
multiplexer may offer the operator a list of candidate hosts for
crew add, and nothing else. - A second managed front door. A peer never runs
tailscale serveand nevertailscale funnel— the prohibition generalises to any tunnel offering a public URL. - Transparent failover / leader election. §14.
- Write queuing or automatic retry. §10.3.
- A crew-wide filesystem, transcript store or audit log. Journal, uploads and audit are host-local by rule (§2).
- Streaming events in v1. §16.
- Standalone-from-Herdr graduation. This document constrains the protocol's vocabulary so graduation stays possible (RFC #67); it does not commit to it and adds no driver abstraction.
Named here so v1's shape does not foreclose them, and so nobody mistakes a reservation for a plan. One entry below has since been discharged and is kept in place with its result, because the useful record is the reservation and the run that closed it, side by side:
- Streaming freshness. A peer→lead push or long-lived stream replacing the poll of §10.1. The version header (§7) is what makes adding it a negotiation rather than a flag day. Nothing in v1 implements or half-implements this.
- An upload-read route (§5), if a use case ever needs the lead to serve a peer-stored image.
- A non-Herdr peer — EXERCISED on 2026-09-08 for both tmux AND zellij, no longer a reservation.
The seam (§2,
ADR 0011) was walked for the first time
in the VM lab: a tmux peer, enrolled under a herdr lead, with a real ssh reach, its own
config root and its own trust store. Every feature in this document's peer table was driven on
that peer through the lead: the merge carried its own tmux ids (
%0,$0,@0), a pane read returned its grid, a typed reply ran a command in its shell,send-keyslanded, ametachord came back as the mux contract'srefusedwith tmux's own reason,historyansweredno-sessionoff the peer's own disk, an upload wrote to the peer's uploads dir and handed back a pastable path,launchersread the peer's ownlaunchers.tomland not the lead's,/api/config?host=published the peer's tmux capability block distinct from the lead's herdr one, the deputy warrant armed on it in two phases, andcrew updatelevelled it over the operator's ssh. The read-only mux conformance set was run against its panes through the lead (scripts/crew-mux-probe.ts): 10 of 12 checks pass, and the two that do not are properties of §5's route table rather than of the peer — four port verbs have no forwardable route, and the pane-read route fixes the grid request shape. Run adapter-locally on the peer itself, the same twelve pass. A zellij peer was then run the same way, on the same day, on a second member of the same crew: one zellij session with three tabs and two splits, the merge carried its own ids (terminal_0, the constant spacesession,tab_0), every feature in the peer table answered, ametachord came backrefusedwith zellij's own reason, and the same 10 of 12 through the lead and 12 of 12 adapter-locally came back — the identical two failures, which is what turns "a property of the route table" from a reading into a measurement. It found two things tmux could not: a tab's pane count came off zellij's own listing, which counts the plugin panes the adapter drops, and a launcher row beside any zellij pane failed outright because zellij reports no pane working directory and the empty string reached its CLI as a flag value. Both are now contract-owned rules (Counts, A blankcwd), met by all three adapters. What the runs found, and where each finding is carried:MUX_CONTRACT.md§ Conformance across a crew link, which also records the two operator-visible gaps neither leg could close from where it stood. One: a peer whose multiplexer dies keeps its panes on the merged snapshot while its host row still reads healthy, becauseServerSummaryhas no per-host equivalent ofSnapshotResponse.bridge; that reproduced on both multiplexers. Two: a forwarded WRITE was given the lead's POLL budget (§10.1, 1200 ms by default), and a launch onto the zellij peer straddled it —write_outcome_unknownover a tab that had in fact been created; closed on 2026-09-08, a forwarded write now carries its ownWRITE_BUDGET_MSand §10.1 holds the rule. Both were crew-side decisions; the first is still recorded rather than taken. The version-skew leg ran on the same day, 2026-09-08, and it closes this entry. A lead built from the real released 1.6.0 tag took over the lab's own crew — same trust store, same members, same addresses — and led the tmux member and the zellij member, both built from this milestone, for the whole run. Every read in the peer table answered on both peers: the merged snapshot carried all of both peers' own pane, tab and space ids beside the lead's herdr pane, the host rows and the crew page rows read healthy, a pane read returned each peer's grid, a typed reply ran on it,send-keyslanded, each multiplexer'smetarefusal came back with its own reason,historyanswered off the peer's own disk, an upload wrote to the peer's uploads dir,launchersread the peer's own file, and a launcher ran there. The 1.6.0 lead was then measured against a member rebuilt on 1.6.0 on the same machine, multiplexer and panes: its published bodies are field-identical for the two members bar theversionstring, and its journal held noerror,unknownorunexpectedline — §7.1 states that property and names the test that pins it. Two differences are the lead's own age and are recorded as such rather than as defects: it has no per-host capability answer, so/api/config?host=<peer>returns the LEAD's block and?host=<unknown>returns200instead of404(MUX_CONTRACT.md§ Conformance across a crew link names the consequence and the remedy); and it publishes nolinkState, so §10.2's four states render as the two that build has. Nothing about this reservation is outstanding. - A route-level rule letting a peer adopt a lead it does not already pin. Needed only once a peer can pin more than its single lead — roaming, multiple leads, a mesh — where the transport stops being the whole answer. It would reuse §8.6's signing primitives as a signed handover from the outgoing lead over a canonical string binding the new lead's crew id, member id, fingerprint, address and a timestamp — field-count-disjoint from §8.6's request string, which has fewer LF-separated fields, so the two never verify as one another under a shared key. v1 has no such topology: a peer learns a new lead only by re-joining (§14.5), and the transport already refuses a claim from any member a peer does not pin, so the rule is unbuilt.
- The final product vocabulary for operator-visible surfaces (env keys, action ids, CLI verbs).
Settled by ADR 0012;
collie/lead/peer/creware the words this document uses and they must stay greppable. The general policy for a version-skewed crew.Closed 2026-08-12 by §7.1 ("Version skew inside a protocol version"), which states the class rule this item asked for: the protocol integer is the only thing that refuses, every addition inside a protocol version MUST be additive-optional with absent-means-closed semantics — which is what makes an older build behind rather than a downgrade being forced on anyone — and a build-version difference is an observationcollie crew statusrenders, never a refusal.hellocarries the observed version as an optional response field (§5); §14.6 is now an instance of the rule rather than a statement of its own.- Concrete default values marked as defaults above (
COLLIE_CREW_TIMEOUT_MS = 1200, the 10-minute token lifetime, the 10-minute handover-approval window, the 10-year certificate lifetime, the3 × pollMs/ 15 s staleness threshold) are starting points chosen to be consistent with today's cadence, not measured ones. M4 may move them; the shapes — a budget below the poll interval, a short single-use token, pinning-not-expiry, a threshold above one missed poll — are the contract.
The problem this closes: a lead that dies takes the front door with it, and today the only way
back is collie promote at a keyboard on another machine (§14.4). A deputy is the one peer the
operator names, in advance and while everything is healthy, as eligible to take over. This section
specifies the object that carries that consent — the warrant — and how it reaches every member.
It does not specify the takeover itself.
At most one standing warrant exists in a crew at any instant. Naming a second deputy does not add one: it mints generation N+1 naming the new member, which supersedes the old warrant everywhere it lands. With one candidate there is nothing to rank, nothing to race, and no election — which is the point. Letting any peer claim leadership on lead-silence would let an attacker who can take the lead offline choose the new lead, and ADR 0014 already refused exactly that shape. Pre-designation moves the choice back to the operator, made while the lead is healthy enough to sign it.
The deputy must be an enrolled peer of this crew holding the current secret generation — the same
validation crew approve-promote performs (§14.1). A lead cannot name itself.
A deputy is still a peer. It publishes no managed front door, serves no PWA and no /api/*, and
answers /crew/v1/* exactly as before (§3, ADR 0013).
interface Warrant {
crewId: string;
generation: number; // monotonic on the lead; higher supersedes lower, everywhere
deputyMemberId: string | null; // null ⇒ a REVOCATION warrant, naming nobody
deputyFingerprint: string | null;// null iff deputyMemberId is null
leadMemberId: string; // whose key verifies this
issuedAt: number; // when this GENERATION was minted; does not move on a refresh
refreshedAt: number; // when it was last re-signed by a healthy lead (§18.4)
signature: string; // base64 ECDSA-P256-SHA256 over the canonical string below
}No new crypto (§8.6): the lead signs with the private key behind its own pinned certificate, and a member verifies with the certificate it already pinned as its lead's. No new key, no new algorithm, no new trust anchor, no CA. A member verifying a warrant asks the one question it can already answer: did the member we pinned as our lead sign this?
The fingerprint is the load-bearing field. §14.2 learned this once already: "consent names who may take over" is only true if the key that takes over is the one already pinned. A warrant naming only a member id would let anything presenting that id be accepted.
No address and no roster. An address is a hint the operator may re-point (§4), so binding one would make roaming a warrant failure; a roster would make the warrant a second source of truth about membership.
The canonical string, exactly — eight LF-separated fields behind a fixed domain tag (§8.6):
collie-crew-warrant-v2\n<crewId>\n<generation>\n<leadMemberId>\n<deputyMemberId>\n<deputyFingerprint>\n<issuedAt>\n<refreshedAt>
deputyMemberId and deputyFingerprint are the literal string - in a revocation warrant. An
empty field there would make two different objects share a string.
- A member keeps exactly one warrant: the highest generation it has verified, and within that
generation the highest
refreshedAt. A lower generation is discarded silently; so is the same generation with arefreshedAtno newer than the one held. Monotone on both axes, so a refresh can never walk a warrant backwards. That is the replay defence. - Revocation is generation N+1 naming nobody — a positive, verifiable statement rather than an absence. A member that never hears about it still holds the old warrant; a member that hears it can prove it heard it. An absence could never be told from a lost message.
- Naming a new deputy revokes the old one, by the same mechanism, in one step.
- The generation counter lives on the lead and never resets. It survives revocation, restart and promotion (§14 — a new lead adopts the generation it is handed, then increments). A reset would make an old warrant verify again, which is why a revocation is stored rather than deleted.
Not a fixed expiry from issue. Such a warrant expires precisely when it is needed: the lead is the only party that can re-issue one, so an operator whose lead died on holiday would find the deputy disarmed at the one moment it mattered. That is also what separates this from §14.1's ten-minute approval, which is a consent to a promotion happening now. The two are different objects and must not share a lifetime.
Not standing forever, either. A capability that outlives the operator's memory of granting it is a liability.
So: the generation is standing; the signature is refreshed.
- The lead re-signs the current generation on a healthy sweep — same generation, same deputy, same
fingerprint, new
refreshedAt, new signature — at most once an hour. The refreshed warrant rides the same push that already carries it, and a member already holding this generation at this refresh is not re-pushed, so the steady-state wire cost is one small body per member per hour, not one per sweep. - A warrant is dead at
refreshedAt + 30 days, so it is only ever as old as the last time the crew was healthy. A crew in daily use never approaches it; a crew that has been dark for a month disarms itself. - An expired warrant is not refreshed. It is dead on every clock that holds it, and re-signing it
would silently re-arm a crew nobody has touched in a month.
collie crew deputyis the way back. - Expiry is not revocation. A revocation is immediate but only reaches machines that are up; an expiry reaches every machine but takes 30 days. Both are needed and neither substitutes for the other.
- Validity is evaluated on each verifier's own clock, and §8.6 already establishes that another member's clock is never trusted for freshness. A machine whose clock is a month fast disarms early — the fail-closed direction, and accepted.
What bounds a warrant regardless of its lifetime: the one-warrant invariant, generation-based revocation, the fingerprint binding, and — decisively — the fact that holding a warrant grants nothing by itself. What a takeover additionally requires is specified with the takeover, not here.
A peer's listener is built with exactly one TLS anchor — its lead's certificate — and
server.reload({ tls }) does not swap a pinned ca (§8.1). Therefore a warrant that arrives over
the crew link lands on disk, and is inert at the transport until that member restarts. No
route, signature or warrant can climb that wall.
| Phase | What happens | When it takes effect |
|---|---|---|
| 1 — stored | The lead pushes the warrant to every member (POST /crew/v1/warrant, §5). Each verifies the lead's signature, checks the generation and persists it beside its trust material. |
Immediately. The member now knows who the deputy is. |
| 2 — anchored | The member's next restart builds its listener with the deputy's certificate as a second anchor — iff the stored warrant verifies against the certificate it already pinned as its lead's, is for this crew, names a deputy that is neither itself nor its own lead, arrives with the certificate its fingerprint names, and is not expired on this member's own clock. Any other reading leaves exactly the one anchor it has always had. The full rule, and the honest consequence of a second anchor for §8.1's boolean, are at §8.1's 2026-08-20 amendment. | At the restart. Until then a takeover from that member's side is impossible, not merely refused. |
The push carries the deputy's certificate PEM alongside the warrant, and the receiver accepts it
only if sha256(certPem) equals the warrant's deputyFingerprint. This is the identical rule §8.2
uses at enrollment, for the identical reason: BoringSSL anchors on certificates, so a hash alone
could never be enforced. The certificate is inside the signature by proxy, not by inclusion —
signing a ~700-byte blob would buy no additional guarantee. A revocation names nobody and therefore
carries no certificate; one that arrives with a certificate is refused.
The receiving order is the rule, and it runs outside-in: shape, then whose warrant this is, then the signature, then the certificate that rode with it, then the clock, then supersession. A caller who cannot sign therefore never learns which generation this collie holds. A refusal costs no write: a warrant that does not verify leaves the member holding exactly what it held before.
Re-push rides the sweep the lead already runs — there is no second timer (§10.1, §11). Each poll's
snapshot answer carries the member's warrantGeneration / warrantRefreshedAt (§5), so the
comparison is two fields on an exchange that already happens and costs no dial to decide. Only a
member genuinely behind is dialled, which covers three cases with one mechanism: a member that was
offline when the deputy was named, a member that has never heard of warrants, and every member once
an hour when the signature is refreshed. A member that did not answer is skipped, not pushed to
blind — it has said nothing about what it holds, and a second dial into a dead link is a second
failure per tick for no information. The push runs off the tick and is never awaited, so a sweep
still costs one strict budget (§10.1).
The warrant and the lead's designation land in the trust store as optional top-level fields
(deputy, warrant), sibling to pendingHandover (§14.1). No new state file, and solo writes
none of it (§11). parseTrustStore builds its result from an explicit field whitelist, so each field
is named in both the validator and the returned result or it is silently dropped on every read —
§14.1 records that exact trap. TRUST_STORE_VERSION stays 1: the fields are read as optional
and an unknown store version is refused, so bumping it would make an updated collie reject its own
pre-amendment store (§14.6's reasoning, verbatim). A malformed warrant or designation invalidates the
whole store rather than being read around — a hole in a trust file is an unpinned member.
A peer stores the warrant and the deputy's certificate; it does not store the deputy
designation, which is the operator's decision on the lead. Who the deputy is, on a peer, is inside
the warrant.
X-Crew-Protocol stays 1. The route is new (a 404 is closed everywhere), and the two response
fields are additive-optional with an absent pair reading as "holds no warrant" — the reading that
makes the lead push rather than assume. Verified precedent: PeerClient.hello reads protocol and
member by name and passes unknown siblings over without inspection (§7.1).
What is not additive, stated plainly: a pre-amendment member is not warrant-capable and no
amount of protocol politeness changes that — it has no warrant, no second anchor and no route. That is
a capability gap, not a compatibility gap: nothing breaks, one thing is unavailable, and
crew status says which members it is unavailable for. Bumping the protocol integer would be
actively wrong — §7's window is exact-match, so a bump takes every route down between differently
updated members in order to add a feature that degrades gracefully on its own.
Nothing. Every item that was on this list has landed.
(The deposed state, the self-heal and the boot gate were on it until 2026-08-20 and are now §18.11
and §18.12. The collie crew deputy verb was on it until 2026-08-20 and is now §18.13. The pairing
sync, the standby door and the takeover exchange were on it until 2026-08-20 and are now §18.14,
§18.15 and §18.16.)
lastDialledAt: the epoch-ms receipt of the last admitted crew request from this peer's lead,
stamped on the peer's own clock. Every admitted request refreshes it — a poll, a proxied pane read,
a forwarded write — which is §10.2's every landed call is a receipt rule, reached from the other
side of the link and for the same reason: the sweep relaxes to COLLIE_POLL_IDLE_MS while a phone
watching a pane polls at 1.5 s, so a receipt only the sweep refreshed would describe a perfectly
healthy link as quiet.
- In memory, never persisted. It describes a process, and §7.1's rule for exactly this shape applies: a persisted receipt would survive the restart it is meant to report and would then state a falsehood with the authority of the trust store. The process start time covers the boot case — a collie that has just started has never been dialled by anybody, so silence is measured from the later of the last dial and that start, or a reboot would read as maximal silence from its first instant.
- A refusal is not a receipt. Both factors must pass first; the number describes calls that landed, not calls that arrived.
- A refusal on the SECRET is recorded separately, and is not silence either. A
secretfactor means the identity was fine, and on a peer the only identity the transport can attest is its lead's — so it is precisely my lead is calling me and I no longer hold the crew secret, which is §8.4's rotation seen from the side that was dropped. It is what lets §18.12's stranded by a rotation be named rather than guessed at. The request is still refused, exactly as before. - There is exactly one of this number. A door that arms on a fact
crew statusdoes not print is a door nobody can explain, so every reader — the status line, and the deputy's arming rule when it lands — reads this one.
Nothing about it crosses the wire. It is a fact a peer holds about calls it received; no field, no header, no route.
How a different process reads it (amended 2026-08-20). collie crew status is not the bridge —
it is a one-shot verb in its own process, and a number held only in the bridge's memory is a number
it cannot print. So the running process checkpoints the two receipts, the generation its listener
actually anchored, and its deposed state (§18.12) into the runtime marker
(crew-runtime.json, bridge/crew/staleness.ts), on the session-refresh tick it already runs and
never on a new timer.
This does not reopen "in memory, never persisted", and the distinction is structural rather than a
promise. Every clause of the refusal above is closed by where it lands: the marker is not the trust
store, it is rewritten whole at every boot with a fresh bootedAt, and silence is computed
from the later of the receipt and that boot stamp — so a checkpoint left by a previous process is
dominated by the new boot stamp and can never make a link look quieter than it is. checkpointedAt
says how old the checkpoint itself is, which is how a reader distinguishes "the lead is quiet" from
"no bridge is running here". There is still exactly one of each number: the process holds it, and
every reader reads its one copy.
Still nothing crosses the wire. The marker is a local diagnostic file, mode 0600 beside the trust store, and no route reads it or reports it.
Before this amendment a lead dialling a peer that follows someone else got either a TLS refusal (if it was no longer in that peer's anchors) or an admitted request served against a roster that silently disagreed with it. Neither says what happened.
Now: a collie whose pinned lead is not the caller answers 409 Conflict — the status §7
already uses for "we do not agree about who we are talking to" — with:
{ "error": "this collie follows lead \"nas\" since warrant generation 7",
"code": "lead_conflict",
"leadMemberId": "nas",
"warrantGeneration": 7,
"warrant": { "…": "the warrant that deposed the caller, when this collie holds it" } }- Only a collie that HAS a lead answers it. A lead pins its members individually and each of them is a legitimate caller, so the same comparison on a lead would refuse its whole roster.
- It is decided on the caller's claimed identity, and that is sound for a refusal. A verified
§8.6 signature names the member outright; absent one,
X-Crew-Memberis a hint the transport cannot corroborate (§6). A hint is never enough to admit and nothing here admits anything — the caller has already cleared both factors — and the worst a forged header buys is a409naming this collie's own lead plus a deliberately public object. - It names the new lead's member id, its generation, and the warrant — and nothing else. Not an address, not a certificate: the answering member is not a directory, and a member id is already knowable to any admitted caller (§5).
- The warrant rides along only when it IS the proof — a warrant naming the member this collie now follows. A revocation, or one naming somebody else, proves nothing about this conflict. That distinction is what turns a deposition into a self-heal rather than a park (§18.12).
- It carries §6's headers. The status is shared with §7's protocol mismatch; a
409with no version banner would be read as a version skew, which is the one reading it must never get. - The dialling side renders it as a state, never as a generic failure — §10.2's fourth,
conflicted— and does not poll it: there is nothing useful to fetch from a machine that belongs to someone else's view of the crew. - It survives into the lead's own belief about that member (amended 2026-08-20). The registry
holds
health: "conflicted"with the lead and generation the peer named, andcollie crew statusprintsthis peer follows another lead "nas" (warrant generation 7). Folding it intounreachablewould render "this peer belongs to someone else's crew now" as "the laptop is shut", and no amount of waiting fixes the first. The generation is carried beside the id rather than only in the sentence because the operator's next move depends on it: higher than this lead's own is a takeover this machine has not heard about; lower is a peer that has not caught up. - The phone's projection is unchanged, deliberately. §10.2 shows three states, and a
conflictedmember is still one the phone cannot be served from — it renders as unreachable there, carrying the answering peer's own sentence as the reason. The fourth state is the operator's, on a surface where the remedy is a membership decision rather than a retry.
The failure this closes: the old lead was down during a takeover, so nobody could tell it
anything. It comes back up hours later, reads a trust store that still says lead, publishes,
answers the failover proxy's health check with 200 — and the proxy swings the operator's phone back
onto a machine with a stale roster and no knowledge of what happened since.
So: a collie booting into lead mode with a non-empty roster probes its members before it
publishes anything.
- Budget: §10.4's patient budget, concurrent, once. It arms no timer and it repeats never.
- A PROVEN conflicting answer deposes it before it serves a byte (amended 2026-09-01) — the answer must carry a warrant that passes §18.12's what counts as learning: signed by this machine's own key, stamped with this crew's id, at a generation at least its own. Because §18.10's named answer carries that warrant, the deposition and §18.12's self-heal happen in the same boot: a machine that was merely down during a takeover comes back up as a working peer, in one restart, having published nothing in between. That is the common case and it is the whole reason the gate sits at boot rather than at first conflict.
- An unproven claim warns once and changes nothing (amended 2026-09-01) — a member reporting a
warrantGenerationhigher than this machine's own but carrying no warrant, a §18.10 answer with no warrant, and a warrant stamped for a different crew are each logged once at that boot, and the lead keeps publishing. Until 2026-09-01 this section deposed on the bare generation too, and a peer that carried one stale deputy field out of a crew it had left could take a new lead's front door down with it. An answer is evidence only when it proves something; arrival order and a counter are not proof. - Silence from every member publishes anyway. Fail-open on no answer is forced: the common case for "nobody answered" is a lead rebooting first after a power cut, and a lead that refuses to come up because its peers are still booting is an outage manufactured out of a safety check. Fail-closed on a proven conflicting answer is the point — a proof is evidence; silence is not, and neither is an unproven claim.
- An empty roster asks nothing. A lead with no members has nobody who could contradict it.
This is not a peer-side timer and it is not an election (§15). It changes no state on any machine it asks.
A former lead that learns the crown has moved stops being a lead, loudly — and then finishes its own
demotion all the way to peer, on materials both machines already hold.
Exactly one thing: a warrant of a generation at least as high as its own, naming a deputy other than nobody, verified against its own certificate's public key. A lead can verify its own signature, and that is the whole reason the warrant is signed by the lead rather than attested by the deputy: what deposes a machine is its own past consent handed back to it. Nothing else does — not a peer refusing it, not an unreachable roster, not a timeout. (amended 2026-09-01) §18.11's boot gate reads this clause unchanged: a foreign warrant or an unproven higher generation is a warning there, not a deposition.
Expiry is deliberately not a clause. A warrant's 30 days gate what it may arm (§18.4), not what it proves: a machine that refused to believe an expired proof would keep leading a crew that has already moved on, which is the split brain this section exists to close.
Two delivery paths, in order of reliability:
- The new lead tells it —
POST /crew/v1/warrant, arriving at a collie that still believes it leads. The old lead's listener pins nothing inbound (§8.1), so the call is admitted on the crew secret plus a §8.6 signature, which is why that route joined the signable set (§5). The caller must be the member the warrant names: a warrant is public (§8.5), so anyone who ever held one could replay it, and requiring the presenter to be the named deputy means a replay by a third member proves nothing. - A peer tells it — §18.10's answer, when the old lead dials that peer. Best-effort and time-boxed: it works only while that peer's anchor list still contains the old lead's certificate, i.e. until its next restart. Do not build on this path; build on (1) and on §18.11.
- Stops polling. Its roster is void as a lead's roster; dialling it would be a second lead's traffic. It keeps the roster's contents — the self-heal reads the new lead's certificate out of it.
- Stops serving the app front door, and FAILS its health check.
GET /standby/healthanswers non-200, so a failover proxy stops routing to it before an operator notices. This is the property the whole state exists for. - Keeps one page, at every path, at
200, naming the crew, the machine that leads now, the generation, and which of the three outcomes below it is in. A200here beside a non-200on health is deliberate: a human who reaches it deserves an answer; a proxy asking whether to route here deserves a refusal. It istext/plain— it interpolates an operator-typed crew name, and plain text has no escaping question to get wrong on a machine already in a degraded state. - Announces the transition, and never re-enters silently — an audit line (
crew.deposed) and a log line. A machine rejoining a crew by itself must be a thing the operator reads about, not a thing they discover; the announcement is part of the security property, not the UX. - Does NOT tear down its own front door.
tailscale serveis a publishing act owned bycollie serve/unserveand by this machine's operator, who may be elsewhere (ADR 0001). Failing the health check is what makes the un-torn-down door harmless meanwhile, and the page names the command.
On a verified proof the machine completes the demotion in one committed transition:
- Resolve the new lead from its own roster. Take the warrant's
deputyMemberId, find that member locally, and requiresha256(certPem) === warrant.deputyFingerprint. The certificate comes from its own disk, never from the wire — a fingerprint is only a pin if the certificate behind it was already held. - Rewrite the trust store:
role: peer,lead: <that member>,peers: [], crew secret kept, own identity and member id kept, the proof stored as the warrant it holds (so the generation advances and an older one can never be replayed at it), the deputy designation and any pending handover cleared. One write, one audit line — §14.5's a role change, not a re-enrollment, reached from the other direction. - It takes effect at the next process start. Nothing here restarts the bridge: the supervision tier is the CLI's knowledge, and an unsupervised bridge that exited to be revived would simply be gone. This is the same shape §14's demotion already has — demoted on disk, still a lead in memory — and the deposed page plus the failing health check are what make the interval harmless. The exception is §18.11's boot gate, which runs before anything is wired: a machine deposed there resolves its mode from the healed store and comes up a peer in the same boot, with no second process involved.
- Wait to be dialled. A peer never initiates. The new lead is already polling this machine's
address — it adopted that roster entry at the takeover — so the first successful
hello/snapshotcompletes the re-entry. There is no rejoin handshake, because there is nothing to negotiate.
Nothing is minted and nothing is learned. Every certificate involved was pinned before the event, the crew secret is unchanged, and no fingerprint is learned from the message that delivered it. The transition is strictly privilege-decreasing — from the role §8.5 describes as reaching "everything, on every member" to the one that reaches its own terminals and nothing else — which is what makes an automatic membership change tolerable here and nowhere else.
| Outcome | Condition | State |
|---|---|---|
| healed | The warrant verifies and the roster holds a certificate matching deputyFingerprint. |
Transitional. An ordinary peer at the next restart, and a reachable one at the new lead's next sweep. |
| parked — unverifiable | The signature does not verify against this machine's own certificate, or the roster holds no certificate matching deputyFingerprint, or the conflict arrived with no warrant at all. |
Terminal. One page, failing health, and the failing check named. Recovery is collie crew add from the new lead, or collie join with a fresh token. |
| parked — stranded by a rotation | Healed, but the crew secret rotated while this machine was away (§8.4). | Terminal until the operator acts, for §8.4's reason and not this feature's. |
Why a failed proof parks rather than retries. A warrant that does not verify is not a stale message; it is a machine being told something by someone who cannot prove they may say it, and retrying is how a refusal becomes a poll. A warrant naming a deputy this machine never enrolled is either a hand-edited store or a crew it does not belong to. Either way the honest answer is to stop.
The rotation case is reached AFTER the heal, never at it. At the instant a takeover commits the
secret is unchanged (§14.5 reuses it), so nothing at heal time can tell that a rotation is coming.
What tells the returning machine is §8.4's own mechanics: the new lead rotated while it was away,
marked it unenrolled, and now dials it with a secret it does not hold — so its lead is identified
and refused on the second factor, which is exactly the fact §18.9 records. §8.4's rule is not
relaxed; what this section adds is that the state is named rather than mistaken for silence,
for a failure of the self-heal, or for unreachable (§10.2's states are not to be conflated).
The takeover exchange that produces the deposition in the first place, and the standby door.
The verb that mints §18.2's warrant, distributes it and completes §18.5's second phase. It adds no
wire surface: the mint is local, the push is POST /crew/v1/warrant exactly as §18.5 already
specifies it, and the restart rides the operator's own SSH and is never a crew message (ADR 0016).
collie crew deputy <member>, on the lead. Refuses a collie that is not a lead, a member it does not pin, anunenrolledmember, one behind on the secret generation, and itself (§18.1's validation, each refusal named separately so the operator knows which one it was). Then: mint, restart this lead, push to every enrolled peer, probe every peer read-only over SSH, ask once for the whole batch, restart each consented machine. Non-interactive aborts; there is no--yes.- A re-run naming the deputy that is already standing does not mint. §18.3's rule — naming a deputy mints N+1 — is a rule about a change. A re-run is a retry, and the common one: a machine had no SSH record, the operator fixed it and ran the same command again. Minting there would climb the generation on every attempt and make every peer already armed stale, so the re-run would undo the arming it was run to finish. An expired warrant is not re-used; that one mints.
collie crew deputy --revokemints §18.3's revocation, pushes it, and offers the same restart batch — a stored revocation is provable, but a peer keeps admitting the old deputy's certificate until its listener is rebuilt. Revoking when nobody is named writes nothing and is not an error.- A member with no SSH record, or one that could not be restarted, is REPORTED, never silently
skipped:
warrant stored, anchor INACTIVE — restart <member>, increw deputy's own output and increw statusthereafter.
What each side can honestly say about anchoring. A peer knows it exactly — its own process built
the listener, and §18.9's checkpoint carries the generation it built it from. What a lead knows on
its own is what its operator did from this machine, so the armed generation is recorded per member in
crew-ops.json beside the SSH route (ADR 0016 — operator-local, never trust, never a wire field). It
is a lower bound: a peer that restarted for its own reasons has anchored without it moving.
This paragraph used to end there, and that was the bug. It also said no field reports anchoring
"because a lead could not act on one" — which a live drill disproved: the lead accused an armed
deputy of being un-armed, and crew deputy offered to restart it again. §18.17 amends it. The peer now
reports warrantActiveGeneration, the lead prefers that report over this record, and a confirmed
report refreshes the record so the offline view converges. The record survives unchanged as the answer
for a member that is not answering, or is too old to say.
The standby door (§18.15) authenticates the operator's phone with the pairing credential the phone
already holds (bridge/pairing.ts) and nothing else. The deputy must therefore be able to verify a
token the lead minted, so the lead pushes its registry.
| Route | POST /crew/v1/pairing, lead → deputy only |
| Gate | the crew's two factors (§8.1), plus a role check: the caller must be this collie's own lead, and this collie must hold a verified warrant naming itself. Every other peer that ever receives one refuses it. |
| Body | { crewId, leadMemberId, devices: [{ label, tokenHash, createdAt }] } — every field required, because the route is new and a new route may require its own fields (§7.1). |
| Sent | at designation and on every change — a pair, a devices revoke, nothing else. |
| Absent (404) | no credential to verify ⇒ the standby door refuses to arm. Closed. |
-
Only hashes cross. The token was shown once, at claim time, and is not recoverable (
pairing.ts), so a store that leaks yields nothing spendable — the same reasoningPendingInvitealready runs on.lastSeenAtis deliberately not sent: it is a fact about the lead's own traffic that the deputy could not keep true, and it is stamped on a throttle, so including it would make every sixty-second write look like a registry change. -
No second timer and no dial to decide. The deputy reports the digest of the registry it actually holds —
pairingDigest, an additive-optional field onhelloandsnapshot(§5) — and the lead pushes when, and only when, that report differs from what it would send. It rides the sweep the lead already runs (§10.1, §11) and costs no round trip to decide.It is a REPORT and not a memory, and that distinction was paid for. The lead used to remember what it had pushed in a process-local field.
collie crew deputyrestarts the local bridge as its last step (§18.13), so the process that knew it still owed a sync was replaced by one that had never offered it — and nothing ever asked the deputy. A live drill found a designated deputy that never received a registry at all, with no error anywhere: the lead believed there was nothing to do. Asked rather than remembered, the decision survives a restart on either side, converges after a failed push, and corrects a deputy whose file was removed. This is the warrant's own discipline (§18.5), applied to the second thing a deputy needs. -
A replace is wholesale, never a merge, because a revocation on the lead has to be able to remove a device on the deputy.
-
It lands in
standby-devices.json— its own file, its own version integer, 0600 in a 0700 directory, temp-then-rename — and is NEVER merged into the deputy's ownpaired-devices.json. This is not tidiness.PairingStore.enforced()is the registry is non-empty, so a merge would silently switch on the deputy's own write gate for its own operator, on a machine where nobody rancollie pair. A gate the operator did not arm is a lockout waiting for the day they use that machine directly. The synced entries are adopted into the deputy's own registry at takeover commit and only then (§18.16), because after the commit that machine is the lead and the phone must keep working against the credential it already holds. -
A label collision is a FINDING, and it never refuses the sync (amended 2026-08-20, after a live drill). The receiver applies the sync and reports the clashing labels on the exchange —
pairingCollision, an additive-optional field onhelloandsnapshotbesidepairingDigest, with absent or empty meaning no finding.Refusing it was a security bug, not a stricter reading. The refusal froze the deputy's copy at whatever it held when the clash first appeared, so a device revoked on the lead stayed valid at that machine's standby door indefinitely — observed on a live crew:
collie devices revokesucceeded, and thirty-five seconds and many sweeps later the deputy's file still listed the revoked device. It protected nothing, either: a sync never touches the receiver's own registry. It replacesstandby-devices.json, a separate file holding the hashes the door checks against.RFC §16 decision 6 is intact and its refusal is where it belongs: labels are the revoke handle (
pairing.ts→removeDevice), and a silently renamed device is one the operator cannot revoke by the name they know it by — so the ADOPTION refuses. That is the takeover commit (§18.16), where entries actually enterpaired-devices.jsonunder a name; it is guarded twice there, and either guard aborts the whole takeover and writes nothing. The finding rides the exchange rather than the sync's answer for the same reason the digest does: a finding delivered once, on whichever push happened to land, is invisible on every sweep that has nothing to push.Compatibility. A pre-amendment receiver still answers
409 pairing_label_collision; a lead reads it and surfaces the finding, but that build's copy stays frozen until it is updated — a capability gap the lead cannot close from its side, and onecrew statusnames. The lead reads thatcodeand thoselabelsoff the refusal, exactly as it reads §18.10'slead_conflictoff the same status. This409is a refusal and not §7's version skew: the deputy read the body perfectly and declined it for a fact on its own disk. Classifying it as a skew would blame the protocol for a duplicate device label and would leave the lead — the one machine whose operator can rename it — unable to say which label. The labels are the deputy's own device names, so they are surfaced bycollie crew statusthere as received, never re-derived.
The boundary this amends, stated rather than quietly outlived.
bridge/server.tsrecords that pairing is "NOT threaded into the crew surface … a lead does not hold one of this collie's pairing tokens and must never need one." The rule survives verbatim: no crew request is ever admitted by a pairing token, and this route is admitted by the crew's own two factors plus a role check like every other one. What is new is that a browser credential's hash rides a crew route and lands on a peer's disk, which is adjacent enough that the comment there carries the exception and a pointer.X-Crew-Device(§12's forwarded attribution) is untouched, and neither substitutes for the other.
A second HTTP listener the deputy binds, and the one narrow exception to a peer publishing nothing (ADR 0013).
It cannot ride the crew listener, and that is a measurement rather than a preference: §8.1's
amendment records that COLLIE_PEER_BROWSER=1 and a pinned listener are mutually exclusive, because
a browser cannot present the lead's client certificate. A phone is a browser, so the choice was a
second listener or no feature.
-
Bind:
COLLIE_STANDBY_HOST(default127.0.0.1) andCOLLIE_STANDBY_PORT. AbsentCOLLIE_STANDBY_PORTmeans no standby door at all — nothing is bound, nothing is served, and the deputy is a plain peer that can still be taken over from a keyboard by §14's promotion. Absent means closed. A LEAD with the key set binds the port too and answers only the health check, because a failover proxy's fallback backend points at it: a deputy that took over and came back up as the lead would otherwise leave the proxy health-checking a closed port and swinging the phone back onto the machine that died. -
Plain HTTP behind the operator's own ingress. Collie binds it and publishes nothing: no
tailscale serve, neverfunnel, no ownership record. ADR 0001's criterion is untouched — we still manage only what we run and can test. -
Three routes, and no more. No PWA, no
/api/*, no SPA fallback, no/authplaceholder; every other path on that port is a bare404. A route that does not exist cannot be mis-gated. -
Every response on this port carries
X-Collie-Version: <semver>+<sha>(added 2026-09-03). Any path, any status, armed or cold, the404included. It is additive: no body changes and no route is added. The detached updater's health gate needs to know which build came back after a restart, and on a peer it cannot askGET /api/health— that port is behind the crew's mutual TLS, and a wide-bound instance is not on loopback for it either. This port is plain HTTP on its own address in every one of those states, so the answer rides it. The front door'sX-Collie-Buildheader answers a different question, which is the web bundle's id; the standby door'sX-Collie-Versionheader answers the running version. Two ports, two headers, two questions, and/standby/health's body names both facts under their own names too.
| Method | Path | Gate | Answer |
|---|---|---|---|
GET |
/standby/health |
none | 503 + {"state":"cold",…} while the lead is fresh; 200 + {"state":"armed","silentForMs":…} once armed. Both answers also carry version (<semver>+<sha>) and build (the on-disk bundle id). Never a body a stranger can learn a member id from. |
GET |
/standby |
none (a read) | The page, in both states. |
POST |
/standby/takeover |
pairing bearer credential only | Runs §18.16. 409 with the reason while cold — the credential is not even consulted there. |
Armed — the definition, and all three factors are required:
armed ⇔ a VERIFIED warrant on this machine's own disk names THIS machine
∧ now - max(lastDialledAt, processStartedAt) >= COLLIE_STANDBY_ARM_MS
∧ the synced pairing registry is non-empty
COLLIE_STANDBY_ARM_MS default = max(30_000, 2.5 × COLLIE_POLL_IDLE_MS)
- The default is a formula, not a number, and that is the point. At today's defaults both terms
are 30 s. An operator who relaxes the idle poll to save a laptop's battery moves the threshold with
it automatically, instead of discovering months later that their idle crew arms its own door every
night. The threshold must exceed
COLLIE_POLL_IDLE_MSor an idle crew arms itself; the formula guarantees it, and an operator who overrides it below that line gets a boot warning and is not refused. lastDialledAtis §18.9's number, and there is only one of it. The door andcollie crew statusread the same value: a door that arms on a factcrew statusdoes not print is a door nobody can explain.processStartedAtis in the max on purpose. A deputy that has just restarted has never been dialled by anyone; without it, every reboot would arm the door instantly.- An EMPTY synced registry refuses to arm, rather than arming ungated: an ungated takeover button on an unpublished port is a takeover button for anyone who reaches the port.
- Arming is reversible and instantaneous. The lead's next landed call disarms it. Nothing is persisted and no state machine survives it — a door that flaps with the lead's connectivity is correct, because it grants nothing.
The device-header gate is NOT applied here, and that is a deliberate, documented narrowing.
COLLIE_DEVICE_HEADER and pairing compose by AND on /api/* and that stays true everywhere else. The
standby door is the exception because the failover path is precisely when an ingress is
misbehaving: a header the broken proxy should have injected is not a second factor there, it is a
dependency on the component that just failed. The pairing credential has no such failure mode — the
phone holds it and the deputy checks it against a registry on its own disk (§18.14).
The page is server-rendered from the bridge: one sentence, one button, no external asset, no
framework, default-src 'none' with the single inline script admitted by its own SHA-256 hash. That
script exists for exactly one reason — the pairing credential lives in this origin's localStorage
and an HTML form post cannot carry an Authorization header. Every interpolation is HTML-escaped
without exception. While cold the page is a statement of fact with no action on it at all, which
is what lets an operator confirm the door before the bad day rather than during it. A two-machine
crew has no witness, is allowed anyway, and the page says so above the button — there, the operator
is the entire evidence base.
/standby/health is answered by three kinds of machine, and the three answers are the feature: a
lead answers 200 while it leads, a deposed lead answers 503 (§18.12), and a deputy
answers 503 until it arms and 200 after. One name for one question — should anything route
here? — asked of both backends behind one failover hostname.
The one thing that spends a warrant. Three steps, and the order is the safety.
(a) Ask the lead first. One GET /crew/v1/hello at the lead, on §10.4's patient budget, and no
retry — a second attempt is a slower way to get the same answer. If the lead answers, the takeover
is REFUSED and nothing anywhere has changed.
(b) Ask the peers, twice. POST /crew/v1/takeover at every other member, carrying the warrant and
a dial attestation (§8.6). The route is two-phase and phase is additive-optional:
phase |
The peer does | The peer answers |
|---|---|---|
probe (also: field absent) |
Verifies the warrant against its pinned lead's certificate, checks the generation and the clock, checks that the caller is the member the warrant names and presents the key it names, and reads its own lastDialledAt. Changes nothing. |
{ok: true, witness: "silent", lastDialledAgoMs} — or {ok: false, code: "lead_is_alive", lastDialledAgoMs} |
commit |
Re-pins its lead to the deputy on disk, records the generation, keeps its member id and the crew secret. Requires address — where it should dial its new lead, a hint and never an identity (§4). |
{ok: true, adopted: true, restartRequired: true, generation} |
Any peer answering lead_is_alive aborts the whole takeover, before the deputy has changed a
byte. That is the partition defence and it is why the exchange is two-phase: a peer its lead dialled
inside the arming window is direct evidence that the deputy's silence is the deputy's own network
problem. This is not a vote — no peer is asked what it thinks should happen; each is asked one
factual question about its own inbox, and one honest no is decisive.
What the peer pins is the certificate its own listener ANCHORED (§18.5's phase 2), never one off the wire, so a commit creates no trust that did not already exist. Every clause of the verification is a question about material the peer already holds: its own crew id, its lead's certificate, the anchored deputy certificate, its own generation counter and its own clock.
(c) Commit locally, LAST. Only after the reachable peers have answered does the deputy rewrite its
own store: role lead, the roster adopted from the one that rode the warrant push (§18.5 —
the deputy holds exactly one roster entry of its own, so without it a takeover would be a takeover
into a crew it cannot see), the old lead carried as an ordinary member, the warrant kept (it carries
the generation counter and it is the proof), the designation dropped and the instant it went recorded
(the crew has no deputy after a takeover — and deputySpentAt is what lets crew status say so
rather than reading the deputy off a warrant that names this very machine), and the synced pairing
registry adopted (§18.14). Then it restarts — the one place the bridge restarts itself, because
the operator asked from a phone and a machine whose store says lead while its process still runs a
peer's pinned listener is a machine nobody can reach.
It exits NON-ZERO (75, EX_TEMPFAIL), and that is a correctness requirement rather than a
convention. Restart=on-failure — systemd's common choice — does not revive a process that
exited cleanly, so a takeover that exited 0 left the store saying lead, the service inactive,
and the operator holding a phone with no shell. Both on-failure and always revive a non-zero
exit, so only a non-zero status is correct under either policy, and the answer's "reload in a
moment" is honest only with it.
Partial success is representable and is not a failure. Every member that did not answer the commit
round is carried as rePinPending on the new lead — an optional, absent-means-closed field on the
roster entry — and is reconciled by §9's rule with no operator step: the new lead's first contact
carries the warrant (POST /crew/v1/warrant), the member verifies it, checks the caller against the
warrant's deputyFingerprint, and re-pins. That is the same decision the commit phase makes, reached
through the route §9 names, and both doors run one implementation so they cannot drift apart. A member
whose sweep answer already reports this generation is confirmed without a push — it has been told
already, by its own boot gate or by the commit round, and pushing again would be refused as foreign
and repeat every sweep forever. A peer that answered lead_is_alive is not partial; it is an
abort.
A peer that never restarted after the warrant push cannot be taken over to, because the deputy's
handshake is refused before HTTP exists (§18.5). From the deputy's side that is indistinguishable from
a peer that is down, so it lands in the pending bucket — which is why §18.13's crew status insists
the un-anchored state is a named finding before the outage.
Compatibility: X-Crew-Protocol stays 1. Both new routes are additive (a 404 is closed in
every case above), phase's absence selects the reading that changes nothing, and every new response
field rides a new route, which may require its own. A pre-amendment member is not takeover-capable
and no amount of protocol politeness changes that — it has no warrant, no second anchor and no route.
That is a capability gap, not a compatibility gap.
warrantActiveGeneration: the generation this collie's listener came up holding — additive-optional
on hello and snapshot (§5), absent meaning "nothing active here, or a build that cannot say".
§18.6 recorded the opposite rule — "a peer never reports which generation its listener was built with, and a lead could not act on one if it did" — and a live drill disproved the second half of that sentence, so this amends it rather than quietly outliving it.
What went wrong. The deputy's own crew status read deputy role ACTIVE at this boot, which was
true. The lead's crew status read warrant stored, anchor INACTIVE — restart <member> … a takeover from there is impossible, about the same machine, at the same minute. Both surfaces were reporting
honestly from what they had: the lead's only anchor evidence was anchoredGeneration in
crew-ops.json, which is written when §18.13's own restart leg completes and at no other time. A
restart performed any other way — an update, the systemd unit, a hand on a keyboard — arms the machine
and moves nothing on the lead. The wire carried storage (warrantGeneration) and never
activation, so the gap could not close on its own, and crew deputy re-run had the same blindness:
it offered to restart a crew that was already armed.
The rule. Activation happens in the peer's own process, so the peer is the authority on it and the
lead's record is a lower bound on what this operator did from here, never a claim about the
machine. Three readings, and the lead's crew status picks between them per member:
| the member's report | the lead prints |
|---|---|
| ≥ the issued generation | armed — stored, and its deputy role is ACTIVE on the machine the warrant names, anchored on every other. The lead tells the two roles apart from its own roster; no wire field carries a role. |
| below it | §18.13's anchor INACTIVE — restart <member> line, unchanged. This outranks the record: the record describes a past restart, the report describes the process running now. |
| absent | the crew-ops.json lower bound — exactly today's behaviour, which is what makes the field additive. |
A confirmed activation is written back to crew-ops.json, by crew status and by crew deputy,
so the offline view (--no-probe, and any member not answering right now) converges instead of
disagreeing until the next designation. It is a refresh and never a creation: a member with no
record is one nobody has ever SSH'd to (ADR 0016), and an anchor generation with no route beside it
would be a record inventing a field the operator never supplied.
crew deputy's re-run asks before it restarts. A member whose record is behind is dialled
read-only once; a report at or above the generation marks it already armed for this generation — that machine reports it active, and it is not probed over ssh, not restarted, and not counted as a target.
A re-run against a crew that is fully armed therefore asks the operator nothing and exits 0.
Compatibility: X-Crew-Protocol stays 1. One additive-optional integer on two existing
responses, absent-means-closed in the direction that preserves the old sentence (§7.1). It is
admissible on hello for member's reason — already knowable to anyone who has cleared both factors —
and it names no secret. It is threaded into the router once, at boot (bridge/index.ts's
activatedGeneration), never read per request: the field is defined as what this listener came up
holding, so a value re-read later would be answering a different question.
One confirm on the phone covers the whole crew, so the preflight the operator reads before that confirm has to cover the whole crew. It does it the only way this protocol allows: each member answers for itself, and the lead reads the answers off the exchange it already makes.
X-Crew-Protocol stays 1. One optional response field and one optional request header, both
additive-optional with absent-means-closed semantics (§7.1).
GET /crew/v1/snapshot's response carries an optional updatePreflight object, beside the
snapshot body and never inside it — that body is the one this collie serves its own browser, and a
crew-only fact has no business in the browser's shape. It takes the same seat warrantGeneration
and pairingDigest already occupy (§5, §18.14, §18.17), for the same reason.
{ "verdict": "green",
"asOf": 1757000000000,
"checks": [ { "id": "working-tree", "verdict": "red", "reason": "tracked changes: bridge/server.ts" } ] }verdictis that member's own top verdict —green,amberorred— carried whole. The lead never re-derives it fromchecks, so a truncated list can never turn a red member green.asOfis epoch milliseconds on the answering member's own clock: when it produced the report. The lead passes it through untouched and renders it per row. This is not a freshness claim about the link (§10.2 is unchanged, and the lead still stamps its own receipt time) — it is a claim about when a check was run, which only the machine that ran it can make.checksis capped at 16 and ordered worst first, so the check that decided a red verdict is the last thing a cap can drop. A member with more sends a synthetic trailing check (checks-truncated, verdictgreen) saying how many were left out: truncation is stated, never silent, and stating it never invents a finding. The poll's budget is §10.1's and this must not grow it.installKindis optional and names how that member is installed —linked-clone,detached-checkout,binary,packagedorunknown(cli/install-kind.ts, ADR 0035). It is a field, never a check id: an id labels a sentence and a sentence gets reworded, while the kind is the fact the lead acts on. A member reportingpackagedis skipped by the turn queue and its leg readspackage-managed(§20). Absent, or a kind this build does not know, reads as unknown, and unknown counts as not packaged (§7.1's closed reading — an absent field never widens, and here the wider behaviour is the old one, which was already safe).- No
remedy. It is a command for the operator of that machine, and the lead's card names a member and a reason, never a shell line to run somewhere else. - Absent means unknown, and unknown is not green. A member that has not run its check, whose
check could not run, or that predates this amendment sends nothing; the lead shows it as
unknownand the confirm treats that as blocking, with the reasonwe could not check <member>.
The report is produced by the member's own collie update --check --local --json
(bridge/update-action.ts's preflightCommand). --local is unchanged and no SSH is anywhere on
this path: a member answers for itself, and never walks anybody else's machines.
GET /crew/v1/snapshot's response also carries an optional version — a bare string, the
answering member's own running version, spelled exactly as hello spells it (§5, the 2026-08-12
amendment). Same seat as updatePreflight and updateRun, same reason, same protocol integer:
X-Crew-Protocol stays 1.
{ "version": "1.4.1" }It exists because the ledger it feeds was empty. A member's version was banked from hello alone,
and the lead's poll never dials hello — it dials snapshot, and fires hello only as a verdict
probe after a sweep has already timed out (§10.4). On a crew whose members answer every sweep, that
probe never fires, so the lead never learned a version at all: GET /api/update/check returned
"version": null for a member that had just answered, the phone's Updates page showed no peer
version, and §20's turn queue could never see a member report the new version — leaving a turn to
be released only by a rollback or by three missed sweeps, and a member that had finished updating
never marked done.
- The lead banks it as an observation, exactly as it banks
hello's — same field, same memory, sameTRUST_STORE_VERSIONof1(bridge/crew/registry.ts,PeerState.version). Two routes report one fact; there is no second reading of it and no second place it is kept. - Absent means "this answer said nothing", and the lead keeps the value it already had. It is
never read as "this member has no version". A member older than this amendment omits the field on
every sweep, and erasing on its silence would unlearn what a
hellohad taught — trading "never learned" for "unlearned once a cadence", which is worse. - The string is passed through untouched and never re-derived. Comparison against a target is §20's business, on the lead's side, from the string the member sent.
- No new route, no new dial, no new budget. The field rides an answer §10.1 already collects, so a crew of any size pays nothing for it.
The lead's own on-demand read (GET /api/update/check) fires one immediate sweep carrying
X-Crew-Preflight: fresh (§6). A member that sees it re-runs its check before answering.
- It is a request, never an order. A member that ignores it is a correct member; its answer is
then simply older, and
asOfsays so. - It is honoured at most once per
PREFLIGHT_TTL_MS(60 s) per member, so a phone sitting on the update page cannot make a member shell out to git anddoctoron every poll. - The member bounds its own wait and answers with what it holds past that bound. The lead bounds the
whole sweep by
UPDATE_ON_DEMAND_POLL_TIMEOUT_MS, the budget its release check already races against. "We could not refresh" is a staleasOf, never a fabricated green. - The periodic sweep never sets it and keeps §10.1's strict budget, unchanged.
It is a report, and it stays one. It names no code, no route, no verb and no version the lead
should install; the lead never asks a member to do anything with it, and ADR 0016 is untouched. No
new /crew/v1/* route, no new verb, and nothing here is persisted: the lead banks it in memory
beside that member's version (bridge/crew/registry.ts, PeerState), and TRUST_STORE_VERSION
stays 1.
A member that predates this amendment omits the field and ignores the header — both readings are the closed one, and neither refuses anything. A lead that predates it passes the sibling over untouched, exactly as it does with the warrant pair. The protocol integer is still the only thing that refuses (§7.1).
A member running a release build, whose lead is running a higher release the lead has marked settled, levels itself to that exact tag — from GitHub over anonymous HTTPS, behind its own preflight, its own detached updater, its own health gate and its own rollback.
No code crosses this link, and no lead distributes anything. ADR 0016's decision sentence stands untouched: code distribution to a member is credentialed by the operator's own SSH and by nothing else. What crosses here is two facts, and the second of them is a mutex token.
There is no new route, no new verb, and no inbound update surface on a member. A member's
/crew/v1/* surface stays exactly what §5 lists. X-Crew-Protocol stays 1.
They travel lead → member on the sweep the lead already makes, because a running member never
dials its lead — there is no member-side poll to hang them on (§5's note on hello's version).
Both are additive-optional and absent-means-closed (§7.1).
| Header | Value | Absent means |
|---|---|---|
X-Crew-Lead-Release |
the lead's own bare version, only when it is a strict release and settled | the lead is on a dev or prerelease build, or has not settled — the member does nothing |
X-Crew-Update-Turn |
<member-name>;<run-id> |
it is not this member's turn |
Settled is the lead's own health gate having passed for the version it is running: its last
UpdateRun reached done for this version, or it has been running this version since before any
recorded run. A lead in preflight, staging, restarting or verifying sends the header absent.
X-Crew-Lead-Release cannot express a version the lead is not running: it is read from the same
collieVersionBare the lead answers hello and /api/health with. A member that receives a version
its lead is not running is a member whose lead lied about itself, which gains an attacker nothing
they did not already have (§8.5).
X-Crew-Update-Turn carries no version, no ref, no URL and no command — a member name and an
opaque run id, nothing else. Neither header is an order. A member that ignores both is a correct
member, and every guard below is evaluated on the member's own side.
Each is a refusal with a recorded reason, and each is on the member:
- Release builds only. Its own version must be a strict release. A
-dev+build never self-levels, full stop. - The header must be present and a strict release.
- Strictly higher, by semver. Equal is nothing; lower is nothing, ever. There is no downgrade path on this link and there will not be one.
- Not a major crossing. ADR 0020's consent is a named operator choice and a header is not one.
- Not a tag it already rolled back from in this run — keyed by (tag, run id) in its own
<state dir>/update.json. A new run id, which only a fresh operator confirm produces, permits exactly one further attempt. - Its own preflight, re-run synchronously immediately before the spawn, and not red. The §19 report is what the lead's page showed a moment ago; this is what is true now.
- A turn naming this member.
- The tag resolves upstream, against the member's own configured repository, anonymously.
Plus a rate limit the member enforces on itself: at most one self-level attempt per hour, whatever the headers say. It is not a tuning knob — it is the guard against a buggy or hostile lead cycling a member through restarts, and the clock is that member's own run record, so it survives the member's restart.
Artifact verification is inherited, not invented: the release manifest plus this platform's
sha256 on a binary install, an explicit refs/tags/<tag> fetch on a checkout. No new mechanism.
The lead holds an in-memory ordered queue of members that are behind, eligible and
preflight-clean, sorted by TrustedMember.enrolledAt — enrolment order, stated so it is stable and
explainable rather than incidental. It grants one turn at a time.
A turn is released on exactly three things: the member reports the new version; the member reports
rolled-back; or the member misses three consecutive sweeps, after which it reads unreachable
and the turn moves on.
A member whose own report names its install kind as packaged is never granted a turn at all,
and its leg reads package-managed rather than waiting. That state is terminal like done: the
queue does not wait on it and a run completes with one present. A packaged machine takes its updates
from its package manager (ADR 0035), so there is nothing a turn could authorise it to do, and parking
it on waiting would say "about to move" about a machine that never will. The lead learns this from
the member's own §19 report over the existing sweep — no new route, no new verb, no new header.
It replaces waiting and nothing else. Every state the sweep OBSERVES outranks it: a packaged member
already on the target reads done, one that reported its own run reads updating or rolled-back,
and one that has missed three sweeps reads unreachable. A packaged machine can still be off, and it
can still be moved by hand on its own console; the leg says what was seen before it says what the
machine is.
The queue is never persisted. §18.9's argument for lastDialledAt applies unchanged — a
persisted turn would survive the restart it is meant to describe — so a lead restart re-derives the
queue from the roster and re-grants, and a member already on the new version is simply not in it.
The lead fires an immediate sweep on three events, so a member starts within one sweep of its
turn rather than within the periodic cadence: the operator confirms; the lead's own health gate
settles; a turn is released. The periodic sweep is unchanged — part of the existing poll, never a
second timer (§10.1), at COLLIE_POLL_MS 1500 relaxing to COLLIE_POLL_IDLE_MS 12000, with its
1000 ms floor intact. That floor matters more than it did: a sweep configurable to fire arbitrarily
fast would be a member-restart amplifier, and the member's one-attempt-an-hour limit is the other
half of that guard.
A member publishes its own run beside its snapshot body, in the seat §19's updatePreflight uses:
{ "updateRun": { "state": "restarting", "to": "v1.5.0", "runId": "…", "reason": null, "updatedAt": 1757000000000 } }updateRun is optional. Absent means the member has nothing to report, which is never success.
It carries no pid, no log tail and no recovery command: those are for the operator of that machine.
The lead needs it because a version alone cannot tell "moving" from "still behind", nor "rolled back"
from "not started".
§7.1 tolerates build skew by design, and a crew levelling is exactly that: no route behaves differently and no code path branches on it. The lead levelling before its members is supported for its whole duration, and nothing here needs to shorten it.
A lead rolled back by hand after its members have advanced leaves them ahead of it. Nothing steps
a member down: there is no downgrade path, and there will not be one — a lead that could move a
member backwards is a lead that could move it anywhere, which is the credential ADR 0016 refuses.
§7.1 makes the resulting skew harmless, and the remedy is collie crew update <member> from the
lead, which pushes the lead's own commit over the operator's SSH.
A member that predates this amendment ignores both headers and omits the field. All three readings are the closed one, and none of them refuses anything. A lead that predates it sends neither header, which is the state every crew is in until an operator confirms an update. The protocol integer is still the only thing that refuses (§7.1).