Skip to content

docs: describe gateway and worker modes end state, accept ADR 0005 (#120) - #127

Open
V3RON wants to merge 11 commits into
claude/adr-0004-c-docsfrom
claude/adr-0005-120-docs
Open

docs: describe gateway and worker modes end state, accept ADR 0005 (#120)#127
V3RON wants to merge 11 commits into
claude/adr-0004-c-docsfrom
claude/adr-0005-120-docs

Conversation

@V3RON

@V3RON V3RON commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Docs, changelog, and the ADR status change for gateway and worker modes. Nothing under src/ or e2e/ is touched: this lands before the implementing PRs, under Accepted — not yet implemented, so that they have a specification to build against rather than a document to catch up to.

Based on claude/adr-0004-c-docs, which already describes ADR 0004's end state.

The ADR is where the decisions live

This PR is what accepts ADR 0005, so the record is still open to extension — the same pattern ADR 0004 followed. Every detail these docs settled is recorded in the ADR itself, in the requirement or consequence it belongs to and phrased as the decision rather than as a divergence from one. New items follow the record's own convention (8a, 19a'); nothing is renumbered. The docs cite the record as the source; there are no "the ADR says X, these docs settle Y" notes anywhere in the tree.

Settlement Recorded in
A gateway must have http.enabled; both it and the worker-mode gateway.url/gateway.token pair are rejected at load, not warned about §2, §3, Consequences
gateway.url is a base URL, wss:// or loopback/tunnel §3, §5
The uplink endpoint is <gateway.url>/v1/uplink; 401 for a missing or unrecognized token, 403 for a valid token of the wrong role §4
/v1/uplink is the one /v1 route a join token opens; the role is disjoint from agent/operator §25
The worker's own uplink as a third admin path; joining a fleet grants that gateway admin over the daemon; the join token is a bearer credential §5
The retention hold ends when the last known gateway-issued lease passes its deadline §6
The worker view carries the worker's effective downloads.policy, read once with config.get on connect §7, §13
UNKNOWN_WORKER for drain/undrain on an unknown id; remove answers {removed:false}; the success bodies §8
worker.* answers UNKNOWN_REQUEST on a worker, and /v1/workers* are unregistered there §8
The worker registry as the gateway's one persisted piece of state, and drain surviving a worker reconnect and a gateway restart §8a, §9, §30, Decision 5
dispatched = a grant or the first progress push; only an immediate NO_CAPACITY leaves a request queued §11
The gw:<instance id>: prefix as the filter for the gateway's own leases, and what it makes possible §14, §30, §34
The gateway's lease.* decides a fleet lease's width; keep its cap at or below every worker's §15
Lease id <workerId>.<workerLeaseId>, routing on the first . §16
device.exec refusals reuse PASSTHROUGH_REFUSED / UNKNOWN_PASSTHROUGH_TOOL; bare adb shell refused; chunks are UTF-8 §19a
stdin is a one-shot string (replacing "forwarded as a stream") §19a, §19c
The requesterId rule: non-admin gated on ownerId; admin must name the owning requester, with no bypass §19a', §19b
The CLI switches on mode from status.get and keeps no refusal list of its own §19c
UNSUPPORTED_IN_GATEWAY_MODE is permanent, 501 / exit 2 — a fleet-wide form would be a new operation, not these growing a second meaning §19d, §34, Consequences, Alternatives
exec.timeoutMs 10 min authoritative, gateway.execTimeoutMs 11 min as a backstop (replacing "both default ten minutes"); EXEC_TIMEOUT exit 10 / 504 §19e
worker.rejected with its reasons and payload; device.exec emitting no event on purpose §22
incompatible is a view state that authenticated — it enters the registry, emits no worker.rejected, and no worker.connected follows §31
WORKER_UNREACHABLE exit 1 / 503; all five new codes with kind, exit code and status §28, Consequences
release --all on a gateway releases gateway-issued leases only, reporting WORKER_UNREACHABLE per unreachable worker §34
Protocol {min: 5, max: 5} with no shim Consequences, §31
Two daemons with distinct SIMLOCK_HOMEs and http.ports; only the gateway needs http.enabled Consequences, Alternatives (hybrid)
The docs land first, as the specification the implementing PRs are written against Consequences (sequencing step 4)

What each doc now says

docs/adr/0005-gateway-and-worker-modes.md, docs/adr/README.md — status Proposed → Accepted — not yet implemented, index row to match, plus the settlements above. Two consistency fixes came with the status: the preamble no longer calls everything outside "Open questions" a proposal, and a Non-goal that pointed at a since-removed open question now points at §19b'.

docs/ARCHITECTURE.md — a new "Gateway and worker modes (ADR 0005)" section: the two modes and what a gateway does not run; a fleet topology diagram; the uplink, with the gateway as the protocol client over it and a second set of contract handlers towards its own clients; the worker view (rebuilt) versus the worker registry (persisted); the fleet queue and dispatch with noWait; the routing policy; lease forwarding, the lease id naming its worker, the fleet-wide one-lease rule, requester namespacing and the prefix the gateway filters its own leases by; device.exec, its ownership rule and its proxying; failure behaviour; why the gateway never touching a device keeps every rule in agent-rules/safety.md holding by construction; and the src/gateway/ boundary. The security model gains its third admin path, and the protocol paragraph carries both bumps to {min: 5, max: 5}.

docs/CLI.md — "Against a gateway": the commands that are identical, and the handful that are not — leasing through the fleet queue, the worker block on the grant, forwarded renew and release, the fleet-wide one-lease rule, aggregated status/list/catalog/events, release --all's fleet scope, simctl/adb switching to device.exec, and nuke/cleanup/doctor answering UNSUPPORTED_IN_GATEWAY_MODE. Plus simlock worker list|drain|undrain|remove, simlock token create --role worker, simlock config set mode gateway, and the new rows in the exit-code table.

docs/HTTP-API.mdGET /v1/uplink; POST /v1/leases/{id}/exec with its SSE shape, its requesterId, and the one-shot stdin note; GET /v1/workers, POST/DELETE /v1/workers/{id}/drain, DELETE /v1/workers/{id}; worker on the lease object and workerId on devices and leases; mode and workers[] on /v1/status; the new codes in the error table. "Multi-host brokering" leaves "Not implemented" — this is it.

docs/CLIENT.mdexec with onOutput and requesterId, and that a client cannot tell a gateway from a worker except by mode in status.

docs/CONFIGURATION.md — the new keys on both sides, and a "Modes" section giving the keys a gateway reads, what lease.* means on one, and the two-daemon layout.

docs/EVENTS.md — a "Fleet" section: worker.connected, worker.rejected, worker.disconnected, worker.removed, worker.drain-started, worker.drain-ended, request.dispatched, each with payload and emission point, plus workerId on republished worker events and why device.exec emits nothing.

docs/IDEAS.md — drops "Cross-machine coordination"; adds what ADR 0005 defers instead: gateway-side file upload for device.exec, reserved capacity slices, a fleet-wide doctor as a new operation, richer routing, and a byte-heavy data plane.

docs/known-pitfalls.md — five accepted gaps: no file transfer for device.exec, no PTY, a dispatched request whose uplink drops, a lease surviving a gateway restart it cannot be renewed through, and local agents sharing a worker's capacity.

CHANGELOG.md — an "ADR 0005" block in Unreleased, in the same not-yet-implemented framing ADR 0004's block uses.

Review rounds

Round 1 — 28 findings, all applied (dd82720). /v1/uplink exempted from the worker-token 403; the uplink admin path added to the security model; the download policy added to the worker view; exec refusals moved to 422 with the existing codes; requesterId introduced; gateway.execTimeoutMs made a longer backstop; the pair rule scoped to worker mode; release --all specified; three "the CLI has an HTTP transport" claims corrected; two-daemon guidance; the retention hold TTL-bounded; unknown-worker-id semantics; EXEC_TIMEOUT given 504; protocol 5; gateway-side lease.* precedence; worker routes 404 on a worker; operator routes fleet-wide; dispatched defined; UNKNOWN_REQUEST in the exit table; list --rules empty on a gateway; the EVENTS gaps; and seven nits. Two settled calls were reversed: drain persists, and WORKER_UNREACHABLE is 503.

Round 2 — 13 findings, all applied (b244637). The blocking one was the requesterId rule, which mis-cited ADR 0003 §4 and inverted it — §4 lets any session name a requesterId, and the CLI's agent id differs from its pid-derived principal, so the old wording would have refused every agent-role CLI exec. Re-settled at all four sites: non-admin gated on ownerId as renew and release are; requesterId for the gateway's admin session, where admin does not bypass. Also: the changelog's leftover {min: 4, max: 4}; worker.rejected narrowed to authentication failures, with a range mismatch entering the registry as incompatible instead; 403 for the wrong role at /v1/uplink; the 11-minute divergence made visible; worker.rejected added to three enumerations; "five" codes; the worker-command JSON reframed; "additive except the protocol bump"; CLIENT's fifth bullet; the CLI keeping no refusal list (settlement changed, per ADR 0003 §11); the start-failing config set; and the three §13 fixes including the gw: prefix filter.

Structural round (775a022). Everything above recorded in the ADR, per the table at the top, and the inline divergence notes removed from the docs.

Confirmation round (3aa889d). One leftover and four refinements: a duplicate gateway.execTimeoutMs parenthetical in CONFIGURATION.md that had survived the de-duplication and now contradicted the ADR; the routing default named in the ADR's config consequence; UNSUPPORTED_IN_GATEWAY_MODE's permanence settled where the alternative is weighed, with IDEAS.md rephrasing the fleet-wide doctor as a new operation rather than these three answering differently later; the changelog and the ADR's sequencing both saying the docs land first as the specification; and EVENTS.md's incompatible-worker pointer aimed at the note under its table.

Validation

  • pnpm install --frozen-lockfile
  • pnpm run format:check — clean (pnpm format run first; .oxfmtrc.json ignores docs/**, so only the root Markdown is enforced)
  • pnpm run lint — clean
  • Every relative link and heading anchor across all 25 Markdown files resolves, re-checked after each round
  • Long-line and lazy-continuation sweep over the changed prose after each round
  • Grepped multi-host, Cross-machine, dataPlane and judged each hit; after each round, swept for every reversed decision to confirm no site was left behind (502, the old drain wording, 400 exec refusals, "any /v1 route", protocol 4 in a view, the old requesterId rule, the 401-for-wrong-role claim, every "these docs settle" note, and the duplicated timeout parenthetical)
  • No changes under src/ or e2e/

Commits are --no-verify: the pre-commit format hook fails on any docs-only commit, because docs/** is in .oxfmtrc.json's ignorePatterns and oxfmt exits 2 when every staged file is excluded (#126).

Part of #115
Closes #120

🤖 Generated with Claude Code

https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz

Move ADR 0005 from Proposed to Accepted — not yet implemented, and update
the index row to match. Nothing in the decision changes; what changes is
its standing: the docs now describe the fleet end state and the code
catches up in the PRs the record itself sequences.

The preamble said everything outside Open questions was "the current
proposal", which contradicts an accepted record, and a Non-goal still
pointed at an open question that no longer exists — both now point at the
requirement that settles that case (19b', file transfer).

Committed with --no-verify: the pre-commit format hook fails on any
docs-only commit, because `docs/**` is in .oxfmtrc.json's ignorePatterns
and oxfmt exits 2 when every staged file is excluded (#126). `pnpm run
format:check` passes over the whole tree.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Add a "Gateway and worker modes" section to ARCHITECTURE.md: the two
modes and what a gateway does not run, a fleet topology diagram, the
uplink (worker dials out, gateway is the protocol client over it), the
worker view and why it is rebuilt rather than persisted, the fleet queue
and dispatch with noWait, the warm-then-free routing policy, lease
forwarding with the lease id naming its worker, the fleet-wide one-lease
rule and requester namespacing, device.exec and its proxying, failure
behaviour (uplink down, dispatched-then-lost, gateway restart, version
skew), why the gateway never touching a device keeps every safety rule
holding, and the src/gateway boundaries that are the reason it cannot.

Give ABOUT.md and README.md a paragraph each on fleets: one URL for
agents and the console, workers dialling out so NAT needs no tunnel, and
the worker keeping its local clients.

Reserve "gateway" for ADR 0005's meaning while doing it: the src/http
frontend is the HTTP frontend, not "the HTTP gateway". One word, one
thing — the alternative is a topology diagram in which "gateway" points
at two different processes.

Settled here, where the ADR is silent: drain is the operator's intent
about a machine rather than something observed on it, so it survives a
worker reconnect and not a gateway restart.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
CLI.md gains "Against a gateway" — every command that behaves the same
(which is nearly all of them) and the handful that do not: leasing
through the fleet queue, the worker block on the grant, forwarded renew
and release, the fleet-wide one-lease rule, aggregated status/list/
catalog/events, simctl and adb switching to device.exec when the device
is on another machine, and nuke/cleanup/doctor answering
UNSUPPORTED_IN_GATEWAY_MODE. Plus `simlock worker list|drain|undrain|
remove`, the `worker` token role, and `config set mode gateway`.

CONFIGURATION.md gains mode, gateway.url/token/label and exec.timeoutMs
on the worker side, gateway.routing/disconnectedRetentionMs/
execTimeoutMs on the gateway side, and a "Modes" section saying which
keys each mode reads.

Settled here, where the ADR is silent:

- Exit codes, on existing numbers rather than new ones and per the
  implementing PRs: WORKER_UNREACHABLE 1 (transport, beside
  DAEMON_CONNECTION_LOST), WORKER_CONNECTED and
  UNSUPPORTED_IN_GATEWAY_MODE 2 (the request is not one this daemon
  takes), EXEC_TIMEOUT 10 (a deadline elapsed) — with the note that a
  passthrough tool exiting 10 itself is told apart by the stderr error
  line, not the number.
- simctl/adb over device.exec need a lease, so `--lease <id>` names one
  when the caller's own is ambiguous, and piped stdin is read to EOF
  and sent as the one-shot string.
- worker drain/undrain/remove treat an unknown id as already-done
  (`{"removed":false}`), the way `token revoke` does; worker.* against a
  worker is UNKNOWN_REQUEST, not a gateway-mode refusal.
- `mode: "gateway"` with `http.enabled: false` is rejected at load, like
  a contradictory TTL pair — a gateway nothing can reach has no safe
  reading. Every other worker-only key in a gateway config warns.
- A grant's `environment` describes the worker's machine, so
  `--export-env` is useless when the device is remote; the docs say so
  where the env table is.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
HTTP-API.md: `GET /v1/uplink` (WebSocket upgrade, join-token bearer,
role `worker`) as the fleet's one inbound connection; `POST
/v1/leases/{id}/exec` with its SSE shape (`output` events carrying
stream and chunk, a terminal `exit` or `error`) and the one-shot `stdin`
note; `GET /v1/workers` plus the drain and remove routes; `worker` on
the lease object and `workerId` on devices and leases; `mode` and
`workers[]` on `/v1/status`; the new error codes in the error table. It
also drops "multi-host brokering" from Not implemented — this is it.

CLIENT.md: `exec` with `onOutput`, and the fact that a client cannot
tell a gateway from a worker except by `mode` in status (parsing a lease
id for its worker is not an answer, it is a bug).

EVENTS.md: a "Fleet" section for `worker.connected`,
`worker.disconnected`, `worker.removed`, `worker.drain-started`,
`worker.drain-ended` and `request.dispatched`, each with payload and
emission point, and the `workerId` added to republished worker events —
additive, so no exception to events rule 6 is needed this time.

Settled here, where the ADR is silent: HTTP status codes (502 for
WORKER_UNREACHABLE, 409 for WORKER_CONNECTED, 501 for
UNSUPPORTED_IN_GATEWAY_MODE, which no route in this version can yet
reach); EXEC_TIMEOUT arrives as the exec stream's terminal `error`
event, since the 200 has already gone out by the time it can happen;
exec chunks are UTF-8 text, so a binary-output command writes a file on
the worker instead; a `NO_CAPACITY` refusal is a stale view rather than
a dispatch and so emits nothing.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
IDEAS.md drops "Cross-machine coordination" — ADR 0005 is that idea,
designed — and gains what the record defers instead: gateway-side file
upload for device.exec, reserved capacity slices, fanning doctor and
cleanup out across a fleet, richer routing (label selectors, requester
affinity), and a byte-heavy data plane.

known-pitfalls.md records five accepted gaps: device.exec carries
arguments but not files, it has no pseudo-terminal, a dispatched request
whose uplink drops may have granted a lease anyway (and why the gateway
must not guess otherwise), a lease survives a gateway restart but
nothing can renew it until the gateway is back, and local agents share a
worker's capacity with the fleet.

CHANGELOG.md extends Unreleased with an ADR 0005 block in the same
not-yet-implemented framing ADR 0004 uses: the additive contract
changes, the new config keys, the new routes, the new events, the four
new error codes, and `ws` as a runtime dependency (Node ships a
WebSocket client but no server) behind the uplink ports.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Twenty-eight findings from an isolated review of #127, in five groups.

Corrections to what the docs claimed:

- `/v1/uplink` is the one `/v1` route a `worker` token may use. Three
  sentences said "any `/v1` route is 403" and so contradicted the route
  they were introducing.
- `device.exec`'s refusals are the existing `PASSTHROUGH_REFUSED` and
  `UNKNOWN_PASSTHROUGH_TOOL` (exit 2, HTTP 422), not `400 BAD_REQUEST`,
  which now means a malformed body and nothing else. They are not new
  codes; `device.exec` is a new way to reach them.
- `WORKER_UNREACHABLE` is HTTP 503, where every other `transport`-kind
  code in the contract's table already sits, not 502. `EXEC_TIMEOUT`
  gains 504 for completeness, still arriving as the exec stream's
  terminal event on the route itself.
- `gateway.execTimeoutMs` is 11 minutes and is described as what it is: a
  backstop for a worker that never answers, deliberately longer than the
  worker's own authoritative `exec.timeoutMs`.
- The `gateway.url`/`gateway.token` pair rule binds in worker mode only;
  in gateway mode both keys are worker-side and warn like the rest.
- The CLI has no HTTP transport and no `--url`. Three places implied one.
  `SIMLOCK_HOME` selects the socket; a remote agent uses the HTTP API.
- Worker routes on a worker daemon are unregistered and answer 404; the
  worker `.*` operations answer `UNKNOWN_REQUEST` on the socket.
- ADR 0005 moves the socket wire to protocol `{min: 5, max: 5}` with no
  shim, so a pre-0005 worker is `incompatible` by range rather than by
  accident. ARCHITECTURE's protocol paragraph, both worker-view
  examples, the version-skew bullet, and the changelog now say so, and
  the changelog no longer calls the release purely additive.

Two settled calls reversed, and one sharpened:

- **Drain persists.** It lives in the gateway's worker registry — the
  gateway's one piece of persisted state, under `SIMLOCK_HOME` with
  owner-only permissions — so it survives a worker reconnect *and* a
  gateway restart. Only `undrain` ends it. The worker *view* is still
  rebuilt on every connect and never persisted; the two are now clearly
  different things.
- **Unknown worker ids split.** `drain`/`undrain` fail with a new
  `UNKNOWN_WORKER` (exit 12, HTTP 404) because draining is an
  instruction about one machine and a silent success would hide a typo
  at the worst moment; `remove` still answers `{"removed": false}`,
  since forgetting an already-forgotten worker is the state asked for.
- `UNSUPPORTED_IN_GATEWAY_MODE` stays exit 2 / 501, now stated as
  permanent rather than pending a later fan-out.

Gaps the docs had not settled at all:

- The worker view carries the worker's effective `downloads.policy`,
  read once with `config.get` on connect (the uplink session is admin).
  Routing needs it before sending a request that depends on a download.
- `device.exec` takes the same optional `requesterId` `lease.request`
  does, and the worker checks it against the lease — **admin does not
  bypass that check on this operation**, because the gateway's own
  session on a worker is an admin session, and without the second check
  one fleet agent's device would rest on the gateway's index alone.
- A bare `adb shell` with no command is refused up front ("needs a
  terminal") instead of stalling ten minutes into `EXEC_TIMEOUT`.
- `release --all` on a gateway releases only gateway-issued leases, on
  every connected worker, reporting `WORKER_UNREACHABLE` for any it
  could not reach. A worker's local leases are never touched.
- A request becomes `dispatched` on a grant or the first `progress` push
  from that worker; only an immediate `NO_CAPACITY` leaves it queued.
- The disconnected-retention hold ends when the last known
  gateway-issued lease passes its deadline, so it is TTL-bounded rather
  than open-ended.
- On a gateway the gateway's own `lease.defaultTtlMs`/`maxTtlMs` decide a
  fleet lease's width; keep its cap at or below every worker's, or
  requests it accepts fail wherever they land.
- Two-daemon guidance: distinct `SIMLOCK_HOME`s, only the gateway needs
  `http.enabled`, distinct `http.port`s if both listen, the worker keeps
  `drivers.*`.
- The security model gains its third admin path — the worker's own
  uplink — and says plainly that joining a fleet grants that gateway
  admin over the daemon, and that the join token is a bearer credential
  on the upgrade request, so use `wss://` or your own tunnel.
- `EVENTS.md`: `worker.rejected` for an uplink that never completes
  `hello` (`incompatible`/`unauthenticated`, optional fields because an
  unauthenticated dial proves no identity), `incompatible` removed from
  `worker.disconnected`'s reasons, and `device.exec`'s lack of an event
  recorded as a deliberate omission.
- `simlock list --rules` lists nothing on a gateway; `UNKNOWN_REQUEST`,
  `PASSTHROUGH_REFUSED`, and `UNKNOWN_PASSTHROUGH_TOOL` join the CLI
  exit table.

Nits: worker ids are UUIDs and every example abbreviates one (routing
splits on the first `.`); the gateway lease example is labelled as such;
the README and ABOUT opening sentences no longer say "on one machine";
two run-on paragraphs rewrapped; the `stdin` tension between ADR 0005
§19a and §19c is named where `stdin` is specified; the stale "no daemon
round trip" claim about `simlock token` in HTTP-API.md is corrected; and
the changelog records that ADRs 0003 and 0004 keep the old "HTTP gateway"
wording, because an accepted record is not edited to match a later
vocabulary.

Committed with --no-verify: the pre-commit format hook fails on any
docs-only commit (#126). `pnpm run format:check` passes tree-wide.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Thirteen findings from a second isolated review of #127.

Corrections:

- **`device.exec`'s `requesterId` rule was wrong and mis-cited.** ADR 0003
  §4 lets any session name a `requesterId` on `lease.request`; the previous
  wording inverted that into "admin only", which would have refused every
  agent-role CLI exec, since the CLI's agent id is not its pid-derived
  principal. Settled and worded identically in ARCHITECTURE, HTTP-API,
  CLIENT, CLI and the changelog: a non-admin session is gated the ordinary
  way, its principal against the lease's `ownerId`, exactly as renew and
  release; `requesterId` (optional, defaulting to the principal) exists for
  the one session that would otherwise bypass that check — the gateway's
  admin session on a worker — and there, unlike renew and release, admin
  does not bypass: the worker compares it to the lease's own `requesterId`
  and answers `FORBIDDEN` on a mismatch. Over HTTP only an `operator` token
  may name one. An admin-role CLI sends its resolved agent id; an agent-role
  CLI sends none.
- **The changelog still claimed `{min: 4, max: 4}`** in two places. The wire
  moves twice this release — 0004 → 4, 0005 → 5 — and what ships advertises
  `{min: 5, max: 5}`; the pre-0004 held holder speaks 3 against a daemon
  speaking 5. The ADR 0005 block now cross-references the bump under
  BREAKING CHANGES instead of restating it, and says every other entry is
  additive.
- **A range mismatch is not a `worker.rejected`.** That uplink
  authenticated; its worker enters the registry as `incompatible` with both
  ranges and no `worker.connected` follows. `worker.rejected` is now
  authentication only — `{ reason, workerId?, label?, protocol? }` with
  reasons `unauthenticated` (401) and `forbidden` (403).
- **Wrong role at `/v1/uplink` is `403`, not `401`.** ADR 0005 §4 says
  "rejects anything else with `401`"; the rest of the API already separates
  a bad credential from a good one that does not permit the route, so the
  docs settle on the split and say they are doing it.
- **The 11-minute `gateway.execTimeoutMs` diverges from ADR 0005 §19e**,
  which gives ten for both while also making the worker's authoritative.
  Equal values make that authority a coin toss on every timeout. The
  divergence is now visible in ARCHITECTURE and in the config table rather
  than buried in a PR description.
- **`worker.rejected` was missing from three enumerations** of what a
  gateway emits (ARCHITECTURE, CLI, changelog), each now noting that it is
  the one fact not in ADR 0005 §22's list and why it was added.
- **The CLI keeps no client-side copy of the refusal list.** The previous
  text had it refusing verbs before sending, which is a second source of
  truth for something that lives with the driver (ADR 0003 §11: frontends
  render the contract). The daemon refuses — `driver.passthrough` locally,
  `device.exec` through a gateway — and the CLI relabels either answer as
  `USAGE` exit 2, exactly as it already relabels the local one.
- **How the gateway recognizes its own leases** is now stated where it
  matters: it filters a worker's `lease.list` by the
  `gw:<its own instance id>:` requester prefix, which is what makes a
  stateless rebuild possible across a restart, and `release --all` and the
  disconnected-retention hold use the same filter — which is what keeps
  both off leases this gateway did not issue.
- `POST /v1/leases/{id}/exec` joins renew and release as a route that
  dispatches its operation directly, so all three answer `403` for another
  requester's live lease.

Smaller: "five" ADR 0005 codes, not four; the worker-command JSON block is
introduced as the known-id success shapes before the unknown-id divergence;
CLIENT's exec list is five bullets for "five things"; the changelog and CLI
name the worker-mode `gateway.url`/`gateway.token` pair failure, with CLI
pointing at CONFIGURATION.md for the full set of start-failing config; three
paragraphs rewrapped.

Committed with --no-verify: the pre-commit format hook fails on any
docs-only commit (#126). `pnpm run format:check` passes tree-wide.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
This PR is what accepts ADR 0005, so the record is still open to extension
— the same pattern ADR 0004 followed. Every detail the docs had settled now
lives in the ADR, phrased as the decision rather than as a divergence from
one, and the docs cite the record instead of arguing with it.

Amended in place, without renumbering (new items follow the record's own
`8a` / `19a'` convention):

- **2, 3** — a gateway must have `http.enabled`, and `gateway.url` is a base
  URL (`wss://`, or loopback/tunnel) with the two worker keys as a pair.
  Both failures are rejected at load rather than warned about.
- **4, 25** — the endpoint is `<gateway.url>/v1/uplink`; a missing or
  unrecognized token is `401` and a valid token of the wrong role `403`, and
  `/v1/uplink` is the one `/v1` route a join token opens.
- **5** — the worker's own uplink is a third admin path beside ADR 0003 §5's
  two, joining a fleet grants that gateway admin over the daemon, and the
  join token is a bearer credential on the upgrade request.
- **6** — the retention hold ends when the last known gateway-issued lease
  passes its deadline, so it is TTL-bounded rather than open-ended.
- **7** — the view carries the worker's effective `downloads.policy`, read
  once with `config.get` on connect, as a routing input and never an
  override.
- **8, 8a, 9** — unknown-id answers (`UNKNOWN_WORKER` for drain/undrain,
  `{removed:false}` for remove), `worker.*` answering `UNKNOWN_REQUEST` on a
  worker with the routes unregistered there, and the **worker registry** as
  the gateway's one persisted piece of state, which is what makes a drain
  survive a worker reconnect and a gateway restart.
- **11** — `dispatched` is a grant or the first `progress` push; only an
  immediate `NO_CAPACITY` leaves a request queued.
- **14, 30, 34** — the gateway recognizes its own leases by the
  `gw:<instance id>:` prefix, which is what lets the index rebuild from a
  worker's `lease.list`; `lease.release-all` and the retention hold use that
  same filter, and `release-all` therefore never touches a worker's local
  leases.
- **15** — the gateway's `lease.*` decides a fleet lease's width, and its
  cap belongs at or below every worker's.
- **16** — the lease id is `<workerId>.<workerLeaseId>`, split on the first
  `.`, path-safe because it appears in routes.
- **19a, 19a', 19b, 19c, 19d, 19e** — refusals reuse `PASSTHROUGH_REFUSED` /
  `UNKNOWN_PASSTHROUGH_TOOL` and add only the bare `adb shell` case;
  `stdin` is a one-shot string (replacing "forwarded as a stream"); the
  `requesterId` rule is written out as its own item — non-admin gated on
  `ownerId`, admin naming the owning requester with no bypass; the CLI
  switches on `mode` from `status.get` and keeps no refusal list;
  `EXEC_TIMEOUT` is exit 10 / `504`; and `gateway.execTimeoutMs` is eleven
  minutes as a backstop longer than the worker's ten (replacing "both
  default ten minutes"), because equal values would make the worker's stated
  authority a coin toss.
- **22, 31** — `worker.rejected` with its reasons and payload, `device.exec`
  emitting no event on purpose, and `incompatible` as a view state that
  authenticated: it enters the registry, emits no `worker.rejected`, and no
  `worker.connected` follows.
- **28** — `WORKER_UNREACHABLE` is exit 1 / `503`, with the other
  `transport`-kind codes.
- **Decision 5** — still true of leases; the registry is named as the
  exception so it does not read as a contradiction.
- **Consequences** — a bullet giving all five new codes with their `kind`,
  exit code and status; a bullet for the protocol move to `{min: 5, max: 5}`
  with no shim; the config bullet gains the two load-time failures and the
  eleven-minute default; and the two-daemon case gains distinct `http.port`s
  and the note that only the gateway needs `http.enabled`.

The docs then drop the four "ADR 0005 §N says X, these docs settle Y" notes
(ARCHITECTURE ×3, CONFIGURATION ×1) and the two "not in §22's list" asides
(ARCHITECTURE, CLI, and the changelog's echo of it), citing the record as the
source instead. The `stdin` sentence stays as a plain one-shot statement.

Committed with --no-verify: the pre-commit format hook fails on any
docs-only commit (#126). `pnpm run format:check` passes tree-wide.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Five items, four of them one-liners.

- **CONFIGURATION.md's `gateway.execTimeoutMs` row** still carried the old
  "ADR 0005 §19e gives ten minutes for both" parenthetical — a second copy
  the earlier de-duplication missed, and one that now contradicts the ADR,
  which records eleven minutes as the decision. Deleted; the sentence before
  it already carries both the reasoning and the pointer.
- **ADR Consequences** name the routing default, `warm-then-free`, beside
  the other new config keys.
- **`UNSUPPORTED_IN_GATEWAY_MODE`'s permanence is now settled where the
  alternative was weighed.** The ADR's fan-out entry said "Deferred", which
  reads as "these three might answer differently later"; it now says
  rejected for these operations, with a fleet-wide form being a **new
  operation** rather than `doctor.run`/`cleanup.run` growing a second
  meaning on a gateway. `docs/IDEAS.md` keeps the idea and is rephrased the
  same way — a distinct name is the point, since a caller should be able to
  tell a one-machine pass from a fleet-wide one by what it called, not by
  which daemon answered.
- **The docs land first, as the specification.** The changelog no longer
  sequences them last, and the ADR's step 4 says why: "Accepted — not yet
  implemented" means the PRs above are written against these docs, not the
  other way round.
- **EVENTS.md's incompatible-worker pointer** points at the note under the
  table, which is where the explanation is.

Committed with --no-verify: the pre-commit format hook fails on any
docs-only commit (#126). `pnpm run format:check` passes tree-wide.

Part of #115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
V3RON pushed a commit that referenced this pull request Sep 6, 2026
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005
§32), for a daemon that owns no devices and fronts the workers connected to it.

- `WorkerLink` drives one uplink as the protocol *client*, with the same typed
  admin client a supervisor uses over a unix socket: `status.get`, `list.get`,
  `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for
  the worker's download policy, which #118's routing needs as an input (§13).
  It refreshes on every worker event about a lease or a device, coalescing a
  burst into one round trip plus one follow-up, and republishes every worker
  event on the gateway's own bus with `workerId` added (§22) -- name and
  emitting module unchanged, because the fact happened in that worker's lease
  engine and rewriting either would make the audit trail lie.
- A worker whose `hello` finds no overlapping protocol range is `incompatible`
  in its view, carrying both ranges, and is asked nothing further (§31). The
  device records a worker reports are narrowed to the contract's status shape
  on the way in, so no driver-private `driverData` crosses the fleet.
- `WorkerRegistry` owns the views and the facts about them: `worker.connected`,
  `worker.rejected` (incompatible, or an uplink turned away at the upgrade),
  `worker.disconnected`, `worker.removed`, `worker.drain-started`,
  `worker.drain-ended`. Retention keeps a disconnected view until its last
  known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has
  elapsed -- a machine that vanished holding a live lease is exactly what an
  operator must still see. Drain is the one piece of persisted gateway state
  (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of
  service stays out across its own reconnect and a gateway restart.
- `GatewayDispatcher` answers the contract from those views through the shared
  `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21);
  `lease.list` and `list.get` report the fleet read-only, each row naming its
  worker -- the fleet made visible, which is this PR's point, and what the
  operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and
  `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34);
  the lease *lifecycle* answers it until #118. The handler table is typed total
  over the contract, so a new operation is a compile error here until someone
  decides which of the three populations it belongs to.
- `GatewayService` is the lifecycle: the uplink listener, one link per worker,
  and the slow tick that backstops event-driven refreshes and sweeps retired
  views.

`boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`,
`src/drivers`, or a frontend, and the single `src/daemon` import is the
dispatch contract -- which the same test asserts is itself core-free, so the
allowance cannot become a back door.

Also settled with the ADR review (#127) and applied here: protocol range moves
to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by
range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view
while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins
the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more
than the worker's authoritative ten) joins the config; a worker naming a
gateway URL without a token, or the reverse, fails its start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Requirement 3 permitted `http://` alongside `ws://`/`wss://`, but the
config loader accepts only `ws:`/`wss:` and fails the start otherwise.
The divergence was resolved in the loader's favour: the uplink is a
WebSocket and nothing else is ever fetched from `gateway.url`, so one
scheme spelling keeps the key unambiguous and a wrong scheme is caught
at start rather than at the first dial.

Amends the record in place rather than superseding it: ADR 0005 has not
landed on main yet, so this revises an unmerged record on its own PR
branch instead of editing an accepted decision.

Also aligns the two other places that stated the wider rule —
CONFIGURATION.md (the key table and the validation paragraph) and
ARCHITECTURE.md's uplink credential note.

Docs-only, so committed with --no-verify: the pre-commit format hook
fails on every docs-only commit (#126).
Decision 5 says a gateway restart loses nothing a worker restart would
not also lose, but ownership was an exception. requesterId round-trips
through requirement 27's `gw:<instance id>:` prefix; ownerId did not.
Every lease a gateway issues is owned by its own uplink principal, so
after a restart the rebuild (requirement 30) could recover which client
a lease was *for* but not who was authorized to renew, release or exec
it -- and `ownsLease` treats an unrecognized lease as authorized, so
leaving it unset would have failed open across every restart.

New requirement 27a: the gateway forwards the owner explicitly on
lease.request and the worker stores it, so it comes back on lease.list
with everything else. Restricted to admin sessions, since ownerId is
what lease.renew/release/list authorize against and a caller free to
name another owner would be naming its way into their lease. Additive
-- omitting the field keeps deriving the owner from the connection.

Chosen over defaulting a rebuilt lease's owner to its unprefixed
requesterId, which would have silently broken the proxy pattern in
requirement 4 across a restart, and over persisting an owner map on the
gateway, which Decision 5 rules out.

Docs-only, so committed with --no-verify: the pre-commit format hook
fails on every docs-only commit (#126).
V3RON pushed a commit that referenced this pull request Sep 7, 2026
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005
§32), for a daemon that owns no devices and fronts the workers connected to it.

- `WorkerLink` drives one uplink as the protocol *client*, with the same typed
  admin client a supervisor uses over a unix socket: `status.get`, `list.get`,
  `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for
  the worker's download policy, which #118's routing needs as an input (§13).
  It refreshes on every worker event about a lease or a device, coalescing a
  burst into one round trip plus one follow-up, and republishes every worker
  event on the gateway's own bus with `workerId` added (§22) -- name and
  emitting module unchanged, because the fact happened in that worker's lease
  engine and rewriting either would make the audit trail lie.
- A worker whose `hello` finds no overlapping protocol range is `incompatible`
  in its view, carrying both ranges, and is asked nothing further (§31). The
  device records a worker reports are narrowed to the contract's status shape
  on the way in, so no driver-private `driverData` crosses the fleet.
- `WorkerRegistry` owns the views and the facts about them: `worker.connected`,
  `worker.rejected` (incompatible, or an uplink turned away at the upgrade),
  `worker.disconnected`, `worker.removed`, `worker.drain-started`,
  `worker.drain-ended`. Retention keeps a disconnected view until its last
  known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has
  elapsed -- a machine that vanished holding a live lease is exactly what an
  operator must still see. Drain is the one piece of persisted gateway state
  (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of
  service stays out across its own reconnect and a gateway restart.
- `GatewayDispatcher` answers the contract from those views through the shared
  `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21);
  `lease.list` and `list.get` report the fleet read-only, each row naming its
  worker -- the fleet made visible, which is this PR's point, and what the
  operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and
  `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34);
  the lease *lifecycle* answers it until #118. The handler table is typed total
  over the contract, so a new operation is a compile error here until someone
  decides which of the three populations it belongs to.
- `GatewayService` is the lifecycle: the uplink listener, one link per worker,
  and the slow tick that backstops event-driven refreshes and sweeps retired
  views.

`boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`,
`src/drivers`, or a frontend, and the single `src/daemon` import is the
dispatch contract -- which the same test asserts is itself core-free, so the
allowance cannot become a back door.

Also settled with the ADR review (#127) and applied here: protocol range moves
to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by
range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view
while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins
the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more
than the worker's authoritative ten) joins the config; a worker naming a
gateway URL without a token, or the reverse, fails its start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
V3RON pushed a commit that referenced this pull request Sep 7, 2026
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005
§32), for a daemon that owns no devices and fronts the workers connected to it.

- `WorkerLink` drives one uplink as the protocol *client*, with the same typed
  admin client a supervisor uses over a unix socket: `status.get`, `list.get`,
  `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for
  the worker's download policy, which #118's routing needs as an input (§13).
  It refreshes on every worker event about a lease or a device, coalescing a
  burst into one round trip plus one follow-up, and republishes every worker
  event on the gateway's own bus with `workerId` added (§22) -- name and
  emitting module unchanged, because the fact happened in that worker's lease
  engine and rewriting either would make the audit trail lie.
- A worker whose `hello` finds no overlapping protocol range is `incompatible`
  in its view, carrying both ranges, and is asked nothing further (§31). The
  device records a worker reports are narrowed to the contract's status shape
  on the way in, so no driver-private `driverData` crosses the fleet.
- `WorkerRegistry` owns the views and the facts about them: `worker.connected`,
  `worker.rejected` (incompatible, or an uplink turned away at the upgrade),
  `worker.disconnected`, `worker.removed`, `worker.drain-started`,
  `worker.drain-ended`. Retention keeps a disconnected view until its last
  known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has
  elapsed -- a machine that vanished holding a live lease is exactly what an
  operator must still see. Drain is the one piece of persisted gateway state
  (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of
  service stays out across its own reconnect and a gateway restart.
- `GatewayDispatcher` answers the contract from those views through the shared
  `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21);
  `lease.list` and `list.get` report the fleet read-only, each row naming its
  worker -- the fleet made visible, which is this PR's point, and what the
  operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and
  `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34);
  the lease *lifecycle* answers it until #118. The handler table is typed total
  over the contract, so a new operation is a compile error here until someone
  decides which of the three populations it belongs to.
- `GatewayService` is the lifecycle: the uplink listener, one link per worker,
  and the slow tick that backstops event-driven refreshes and sweeps retired
  views.

`boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`,
`src/drivers`, or a frontend, and the single `src/daemon` import is the
dispatch contract -- which the same test asserts is itself core-free, so the
allowance cannot become a back door.

Also settled with the ADR review (#127) and applied here: protocol range moves
to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by
range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view
while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins
the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more
than the worker's authoritative ten) joins the config; a worker naming a
gateway URL without a token, or the reverse, fails its start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
V3RON pushed a commit that referenced this pull request Sep 7, 2026
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005
§32), for a daemon that owns no devices and fronts the workers connected to it.

- `WorkerLink` drives one uplink as the protocol *client*, with the same typed
  admin client a supervisor uses over a unix socket: `status.get`, `list.get`,
  `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for
  the worker's download policy, which #118's routing needs as an input (§13).
  It refreshes on every worker event about a lease or a device, coalescing a
  burst into one round trip plus one follow-up, and republishes every worker
  event on the gateway's own bus with `workerId` added (§22) -- name and
  emitting module unchanged, because the fact happened in that worker's lease
  engine and rewriting either would make the audit trail lie.
- A worker whose `hello` finds no overlapping protocol range is `incompatible`
  in its view, carrying both ranges, and is asked nothing further (§31). The
  device records a worker reports are narrowed to the contract's status shape
  on the way in, so no driver-private `driverData` crosses the fleet.
- `WorkerRegistry` owns the views and the facts about them: `worker.connected`,
  `worker.rejected` (incompatible, or an uplink turned away at the upgrade),
  `worker.disconnected`, `worker.removed`, `worker.drain-started`,
  `worker.drain-ended`. Retention keeps a disconnected view until its last
  known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has
  elapsed -- a machine that vanished holding a live lease is exactly what an
  operator must still see. Drain is the one piece of persisted gateway state
  (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of
  service stays out across its own reconnect and a gateway restart.
- `GatewayDispatcher` answers the contract from those views through the shared
  `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21);
  `lease.list` and `list.get` report the fleet read-only, each row naming its
  worker -- the fleet made visible, which is this PR's point, and what the
  operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and
  `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34);
  the lease *lifecycle* answers it until #118. The handler table is typed total
  over the contract, so a new operation is a compile error here until someone
  decides which of the three populations it belongs to.
- `GatewayService` is the lifecycle: the uplink listener, one link per worker,
  and the slow tick that backstops event-driven refreshes and sweeps retired
  views.

`boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`,
`src/drivers`, or a frontend, and the single `src/daemon` import is the
dispatch contract -- which the same test asserts is itself core-free, so the
allowance cannot become a back door.

Also settled with the ADR review (#127) and applied here: protocol range moves
to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by
range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view
while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins
the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more
than the worker's authoritative ten) joins the config; a worker naming a
gateway URL without a token, or the reverse, fails its start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
V3RON pushed a commit that referenced this pull request Sep 8, 2026
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005
§32), for a daemon that owns no devices and fronts the workers connected to it.

- `WorkerLink` drives one uplink as the protocol *client*, with the same typed
  admin client a supervisor uses over a unix socket: `status.get`, `list.get`,
  `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for
  the worker's download policy, which #118's routing needs as an input (§13).
  It refreshes on every worker event about a lease or a device, coalescing a
  burst into one round trip plus one follow-up, and republishes every worker
  event on the gateway's own bus with `workerId` added (§22) -- name and
  emitting module unchanged, because the fact happened in that worker's lease
  engine and rewriting either would make the audit trail lie.
- A worker whose `hello` finds no overlapping protocol range is `incompatible`
  in its view, carrying both ranges, and is asked nothing further (§31). The
  device records a worker reports are narrowed to the contract's status shape
  on the way in, so no driver-private `driverData` crosses the fleet.
- `WorkerRegistry` owns the views and the facts about them: `worker.connected`,
  `worker.rejected` (incompatible, or an uplink turned away at the upgrade),
  `worker.disconnected`, `worker.removed`, `worker.drain-started`,
  `worker.drain-ended`. Retention keeps a disconnected view until its last
  known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has
  elapsed -- a machine that vanished holding a live lease is exactly what an
  operator must still see. Drain is the one piece of persisted gateway state
  (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of
  service stays out across its own reconnect and a gateway restart.
- `GatewayDispatcher` answers the contract from those views through the shared
  `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21);
  `lease.list` and `list.get` report the fleet read-only, each row naming its
  worker -- the fleet made visible, which is this PR's point, and what the
  operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and
  `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34);
  the lease *lifecycle* answers it until #118. The handler table is typed total
  over the contract, so a new operation is a compile error here until someone
  decides which of the three populations it belongs to.
- `GatewayService` is the lifecycle: the uplink listener, one link per worker,
  and the slow tick that backstops event-driven refreshes and sweeps retired
  views.

`boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`,
`src/drivers`, or a frontend, and the single `src/daemon` import is the
dispatch contract -- which the same test asserts is itself core-free, so the
allowance cannot become a back door.

Also settled with the ADR review (#127) and applied here: protocol range moves
to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by
range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view
while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins
the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more
than the worker's authoritative ten) joins the config; a worker naming a
gateway URL without a token, or the reverse, fails its start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants