docs: describe gateway and worker modes end state, accept ADR 0005 (#120) - #127
Open
V3RON wants to merge 11 commits into
Open
docs: describe gateway and worker modes end state, accept ADR 0005 (#120)#127V3RON wants to merge 11 commits into
V3RON wants to merge 11 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Docs, changelog, and the ADR status change for gateway and worker modes. Nothing under
src/ore2e/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.http.enabled; both it and the worker-modegateway.url/gateway.tokenpair are rejected at load, not warned aboutgateway.urlis a base URL,wss://or loopback/tunnel<gateway.url>/v1/uplink;401for a missing or unrecognized token,403for a valid token of the wrong role/v1/uplinkis the one/v1route a join token opens; the role is disjoint fromagent/operatordownloads.policy, read once withconfig.geton connectUNKNOWN_WORKERfordrain/undrainon an unknown id;removeanswers{removed:false}; the success bodiesworker.*answersUNKNOWN_REQUESTon a worker, and/v1/workers*are unregistered theredispatched= a grant or the firstprogresspush; only an immediateNO_CAPACITYleaves a request queuedgw:<instance id>:prefix as the filter for the gateway's own leases, and what it makes possiblelease.*decides a fleet lease's width; keep its cap at or below every worker's<workerId>.<workerLeaseId>, routing on the first.device.execrefusals reusePASSTHROUGH_REFUSED/UNKNOWN_PASSTHROUGH_TOOL; bareadb shellrefused; chunks are UTF-8stdinis a one-shot string (replacing "forwarded as a stream")requesterIdrule: non-admin gated onownerId; admin must name the owning requester, with no bypassmodefromstatus.getand keeps no refusal list of its ownUNSUPPORTED_IN_GATEWAY_MODEis permanent,501/ exit 2 — a fleet-wide form would be a new operation, not these growing a second meaningexec.timeoutMs10 min authoritative,gateway.execTimeoutMs11 min as a backstop (replacing "both default ten minutes");EXEC_TIMEOUTexit 10 /504worker.rejectedwith its reasons and payload;device.execemitting no event on purposeincompatibleis a view state that authenticated — it enters the registry, emits noworker.rejected, and noworker.connectedfollowsWORKER_UNREACHABLEexit 1 /503; all five new codes withkind, exit code and statusrelease --allon a gateway releases gateway-issued leases only, reportingWORKER_UNREACHABLEper unreachable worker{min: 5, max: 5}with no shimSIMLOCK_HOMEs andhttp.ports; only the gateway needshttp.enabledWhat 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 withnoWait; 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 inagent-rules/safety.mdholding by construction; and thesrc/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, theworkerblock on the grant, forwarded renew and release, the fleet-wide one-lease rule, aggregatedstatus/list/catalog/events,release --all's fleet scope,simctl/adbswitching todevice.exec, andnuke/cleanup/doctoransweringUNSUPPORTED_IN_GATEWAY_MODE. Plussimlock 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.md—GET /v1/uplink;POST /v1/leases/{id}/execwith its SSE shape, itsrequesterId, and the one-shotstdinnote;GET /v1/workers,POST/DELETE /v1/workers/{id}/drain,DELETE /v1/workers/{id};workeron the lease object andworkerIdon devices and leases;modeandworkers[]on/v1/status; the new codes in the error table. "Multi-host brokering" leaves "Not implemented" — this is it.docs/CLIENT.md—execwithonOutputandrequesterId, and that a client cannot tell a gateway from a worker except bymodein status.docs/CONFIGURATION.md— the new keys on both sides, and a "Modes" section giving the keys a gateway reads, whatlease.*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, plusworkerIdon republished worker events and whydevice.execemits nothing.docs/IDEAS.md— drops "Cross-machine coordination"; adds what ADR 0005 defers instead: gateway-side file upload fordevice.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 fordevice.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/uplinkexempted 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 to422with the existing codes;requesterIdintroduced;gateway.execTimeoutMsmade a longer backstop; the pair rule scoped to worker mode;release --allspecified; three "the CLI has an HTTP transport" claims corrected; two-daemon guidance; the retention hold TTL-bounded; unknown-worker-id semantics;EXEC_TIMEOUTgiven504; protocol 5; gateway-sidelease.*precedence; worker routes404on a worker; operator routes fleet-wide;dispatcheddefined;UNKNOWN_REQUESTin the exit table;list --rulesempty on a gateway; the EVENTS gaps; and seven nits. Two settled calls were reversed: drain persists, andWORKER_UNREACHABLEis503.Round 2 — 13 findings, all applied (
b244637). The blocking one was therequesterIdrule, which mis-cited ADR 0003 §4 and inverted it — §4 lets any session name arequesterId, 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 onownerIdas renew and release are;requesterIdfor the gateway's admin session, where admin does not bypass. Also: the changelog's leftover{min: 4, max: 4};worker.rejectednarrowed to authentication failures, with a range mismatch entering the registry asincompatibleinstead;403for the wrong role at/v1/uplink; the 11-minute divergence made visible;worker.rejectedadded 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 thegw: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 duplicategateway.execTimeoutMsparenthetical 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-lockfilepnpm run format:check— clean (pnpm formatrun first;.oxfmtrc.jsonignoresdocs/**, so only the root Markdown is enforced)pnpm run lint— cleanmulti-host,Cross-machine,dataPlaneand judged each hit; after each round, swept for every reversed decision to confirm no site was left behind (502, the old drain wording,400exec refusals, "any/v1route", protocol 4 in a view, the oldrequesterIdrule, the401-for-wrong-role claim, every "these docs settle" note, and the duplicated timeout parenthetical)src/ore2e/Commits are
--no-verify: the pre-commit format hook fails on any docs-only commit, becausedocs/**is in.oxfmtrc.json'signorePatternsand 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