Skip to content
state-vectorPublic

About

bridge: decisions, activity flows, presence — agents + humans, one surface

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

bridge — self-hosting

bridge is a control plane for coding-agent fleets: a live board with confidence/risk/completion gauges, a gateway that claims tasks and dispatches them to agent CLIs on your machines, and a human inbox for sign-off. The public site covers what it does and why; this README gets the app running in your own Cloudflare account.

Just want to use it? The shared hub is live at https://hub.fleetbridge.run — sign in with GitHub at the same URL and bridge pair https://hub.fleetbridge.run; no deploy needed. The rest of this README is for self-hosting.

Everything runs on the Cloudflare free tier: one Worker, one SQLite-backed Durable Object, hibernating WebSockets, worker assets for the web UI.

What you need

  • A Cloudflare account (free)
  • Node.js 20+
  • One machine to run a runner on (macOS or Linux) with opencode installed

1 · Deploy the hub (Worker)

git clone https://github.com/state-vector/bridge && cd bridge
npm install

cd packages/worker
npx wrangler login                      # or: export CLOUDFLARE_ACCOUNT_ID=<id>
npx wrangler deploy
# note the printed URL: https://bridge.<your-subdomain>.workers.dev

Secrets (stored server-side, never in the repo):

npx wrangler secret put BRIDGE_INGEST_TOKEN    # generate: openssl rand -hex 24 (legacy FLOWS_INGEST_TOKEN still accepted)

2 · Human sign-in (GitHub OAuth)

The web UI is gated by GitHub OAuth (30-day HMAC session cookie):

  1. github.com → Settings → Developer settings → OAuth Apps → New OAuth App
    • Homepage: https://bridge.<your-subdomain>.workers.dev
    • Callback: https://bridge.<your-subdomain>.workers.dev/login/callback
  2. Store the secrets:
cd packages/worker
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
  1. Set the login allowlist (default deny — without this secret only invited GitHub logins can sign in):
npx wrangler secret put GITHUB_ALLOWED_USERS   # comma-separated GitHub logins (e.g. "rayh"), or "*" for any account

Inviting teammates: workspace members self-serve — the 👥 button next to the workspace picker invites/removes members by GitHub username (validated against the GitHub API at invite time; optional GITHUB_TOKEN secret raises the lookup rate limit). An invited login can sign in via the GitHub button even when not on GITHUB_ALLOWED_USERS. Everyone holds the same rights — any member can invite or remove — and the workspace creator cannot be removed.

3 · Run a runner (the gateway)

The runner claims todo tasks from the board and dispatches each to an agent CLI (opencode) in an isolated git worktree — one PR per task, progress reported back to the board. Worktree teardown is eventual (BR#3633): a done task's tree goes immediately (uncommitted work preserved onto a wip/<task>-<date> branch first); any tree no running session holds is swept by the periodic hygiene pass — at once when the task reads done/superseded/todo, otherwise after 7 days regardless of status — so a hub outage, an unresolvable task id, or a parked card can never pin disk forever; trees drain oldest-first, capped per tick. Claim races are noise, not failures: before claiming, the runner re-reads the card and skips silently when another dispatcher won; a card still in_progress whose dispatched session died (no fresh session stamp or hub heartbeat for 10 minutes — the same bar the watchdog uses) has its stale claim released and is re-claimed fresh; and the dispatch circuit breaker counts only genuine agent-CLI failures (spawn, auth, guardrails, provisioning) — lifecycle rejections from races never count toward it.

curl -fsSL https://raw.githubusercontent.com/state-vector/bridge/main/install.sh | bash
bridge pair https://bridge.<your-subdomain>.workers.dev

Pairing prints a device code — approve it in the web UI (/pair) and the runner starts. It stays running; bridge status shows what it's doing. Useful env: BRIDGE_MAX_SESSIONS (WIP limit), BRIDGE_WORKDIR, BRIDGE_AGENT (opencode agent name), OPENCODE_BIN. The device declares its class at pair/start (a runner pairs as a gateway — there is no checkbox) and its label is pre-filled with the bare hostname slug (e.g. macbookpro), editable and display-only — the label is never identity. The gateway class comes from the token's gateway tag, not the name, and Connect lists gateways under Gateways with the agents they dispatched, separate from your interactive agents.

Prefer building from source?

cd packages/cli && cargo build --release
./target/release/bridge pair https://bridge.<your-subdomain>.workers.dev

Living on the edge? Every push to main rebuilds the binaries into a rolling dev pre-release (releases/latest and plain install.sh never see it):

curl -fsSL https://raw.githubusercontent.com/state-vector/bridge/main/install.sh | bash -s -- dev

Run on a server

The runner is a long-lived process: keep it alive with systemd (linux) or a LaunchAgent / nohup (macOS).

bridge doctor            # checks config, token, agent CLIs + agent profiles, git, and the merge-train executor prerequisites (gh authed + repo scope + worker-class identity + lane-repo push, git) — every failure names the fix
bridge pair https://bridge.<your-subdomain>.workers.dev
bridge service install   # linux: writes systemd user units — supervisor (Restart=always,
                         # backoff + jitter) + death telemetry + give-up escalation
systemctl --user enable --now bridge

Headless log view — everything the fleet did, same event feed the webapp's Log view renders:

bridge logs                 # last 30 events, oldest → newest (age · actor · op · task · detail)
bridge logs --limit 100     # deeper
bridge logs -f              # follow — polls every 5s, prints new events live
bridge logs --task BR#123   # filter to one artefact
bridge logs --json          # raw JSON for scripts

The unit logs to ~/bridge.log (both streams, append:) instead of the journal — user journals are unreadable on many minimal cloud images (journald Storage=volatile, or the session predates linger), so journalctl --user dead-ends there while the service itself runs fine. It also bakes the install shell's PATH into the unit (with the configured agent CLIs' dirs first): systemd's minimal default PATH misses ~/.local/bin, which left agent CLIs unrunnable under the service. Re-run bridge service install after moving agent CLIs, or set PATH in ~/.bridge/env to override.

The daemon's death is loud (BR#4682): the unit uses Restart=always with exponential restart backoff (5s→60s) and a 0–7s start jitter, so the runner revives from a crash or a clean exit without human action. Every restart-causing death fires an ExecStopPost hook that posts one [daemon-death] note per distinct cause (result / signal / exit code + last log lines) to the alarm sink card — deduped to one note per 10 minutes per cause, so a crash-loop can't spam the board — and always appends a local receipt to ~/bridge-death-telemetry.log (the no-silent-deaths guarantee even when the hub itself is unreachable). Deliberate systemctl stop/restart log locally without alarming. If the supervisor ever truly gives up (start-limit-hit, effectively unreachable at 50 starts / 10 min against the 60s backoff cap), a separate give-up escalation unit posts one [daemon-death ESCALATION] note naming the manual intervention needed. The unit's WorkingDirectory is the user's home — never the directory the installer ran from.

Runner log format (BR#4400): every operator-visible line from the runner — dispatch loop, in-process merge-train leg, store/session/keychain, CLI, canary — flows through one mechanism with one shape:

[2026-10-28T22:32:25Z] [train] [state-vector/bridge@main] queue: BR#4363 → BR#4362 → BR#3822
[2026-10-28T22:33:10Z] [dispatch] [BR#4371] 21m in · heartbeat text…
[2026-10-28T22:33:12Z] [train] tick error: hub rpc error (train_lease): kb temporarily unavailable — retry

Fields: [<iso-utc>] [<subsystem>] (train | dispatch | pair | store | session | keychain | cli | canary) [<scope>] (the lane, card, or workspace the line is about — omitted when process-global), then free text where event-style prefixes (queue:, lease:, error:) are the convention. The desktop gateway and the bridge CLI compile the same module (packages/desktop/src-tauri/src/logging.rs), so both binaries emit identically.

Server prerequisites: the agent CLI itself (e.g. opencode) installed and authenticated — bridge doctor verifies. Per-agent models are required ("models" in ~/.bridge/config.json); the runner refuses to dispatch without them. Models are the agent CLI's business, not the hub's: the runner injects the pinned id into the agent CLI's own config, and the CLI resolves it against its own provider config — bridge doctor reports the pin (and warns when unset) but does not validate it against anything hub-side. ~/.bridge/config.json is the knob surface: max_sessions, backends, agent, poll_secs, timeout_mins, model overrides.

The desktop app (gateway with UI) is packages/desktop: npm run dev -w @bridge/desktop.

Mobile app (iOS) — the voice console

packages/mobile is the no-gateway surface: ui/ is the Fleet + For You + Voice tab (browser-testable Vite/React over hub REST + WebSocket), shell/ is a Tauri 2 iOS target wrapping it (com.statevector.bridge.mobile).

The Voice tab is the narrow desktop chat, by ear:

  • Tap to talk — SFSpeechRecognizer with requiresOnDeviceRecognition: on-device speech-to-text, live partial transcript, audio never leaves the phone. Answers are spoken back with AVSpeechSynthesizer (offline voices); tap mid-answer to interrupt.
  • The brain is the same engine as the desktop console — Apple's on-device model (Foundation Models, iOS 26+ with Apple Intelligence) — but it drives bridge MCP tools over a prompt-protocol loop: it can orient, search_knowledge, get_artefact and read the ask inbox before it answers, citing BR# ids. Read-only by construction: it never claims or dispatches work, and answering an ask is confirm-first (the app reads the proposed answer back; you confirm by voice or tap).
  • Connect: hub URL + a bridge token pasted from the hub's Connect tab (the machine plane — same token class an agent uses). The Fleet/For You tabs keep working with the OAuth web sign-in as before.

Build (simulator, no signing needed):

npm install
npm run build -w @bridge/mobile-ui
cd packages/mobile/shell
npx tauri ios build --target aarch64-sim
# product: gen/apple/build/arm64-sim/Bridge.app — boot it in any iOS 26+ simulator

One rendering path. The web UI and the desktop shell render the same view components (Top, Backlog, Completed, task detail with dep graph and live work tree) from the shared kit @bridge/ui-kit, over the same data layer — the desktop opens a live WebSocket to the hub with its paired gateway-class token (bearer on /api/kb/* + /kb-ws; admin routes stay cookie-only), so both surfaces always look and update identically.

The board is push-driven — no polling (BR#4362). The socket carries state, not just a doorbell: every mutation streams a kb.event frame, and the hub pushes a coalesced kb.board snapshot (at most one board build per second, no matter the mutation rate or how many clients are connected) plus a hydration snapshot on connect. Clients apply frames to their store and fetch nothing on a timer; GET /board stays as a 2s-cached hydration path for stragglers. Desktop shells whose webview can't hold a cross-origin socket tail the events mirror adaptively (2s while busy, backing off to 30s idle) and coalesce event bursts into one refresh — the 2026-10-29 overload was every client refetching the full board per event. Pull surfaces are rate-limited per identity as a backstop (/board 30/min, /events 120/min; 429 + Retry-After naming the socket) — pollers brake themselves.

Beyond dispatch it carries three human-facing surfaces:

  • For you + Chat — the decision inbox (proposed decisions, blocked work, open questions, read-only) and a console chat: ask about the board in natural language. Retrieval runs over the hub (/api/kb-machine/search, paired token); the answer is summarised by Apple's on-device model (macOS 26+ with Apple Intelligence) and falls back to the configured backend CLI one-shot otherwise. The console never claims or writes work.
  • Decision notifications — the app watches the inbox and raises an OS notification whenever a new proposed decision arrives (the click jumps to For you). Verify-verdict notifications state the ask ("Accept verification of BR#X? — verdict PASS"), not the raw receipts title. No runner required.
  • Guided sign-back-in — if the run gate signs the desktop out (the identity law reserves gateway dispatch for gateway-class tokens: a pre-gateway token surviving an app upgrade), the sign-in screen says so in plain language — what happened, that nothing was lost (hub URL and settings are kept), and that signing back in is one click: the browser opens itself, and the vended gateway-class token re-associates with the same stable device id. Legacy board rows keep their labels; only this device's token rotates. (A token the hub rejects mid-run is not a sign-out anymore — see the self-heal law below: the gateway parks loudly and recovers on its own, BR#4113.)

Runner config file

All runner knobs live in ~/.bridge/config.json (written through a single atomic save; safe to edit by hand — settings in the desktop app apply by updating the same file). Credentials never live there: the gateway token is stored in the OS keychain (data-protection keychain on signed macOS builds; ~/.bridge/gateway-token, mode 0600, on unsigned dev builds and Linux), and the device principal is write-once at ~/.bridge/principal (0600) — bridge reset-principal is the only local clear. The principal is the device credential and anchors identity: the hub vends it on first pairing (dev_ + 24 random bytes hex, generated once, never regenerated), the device presents it on every pairing and desktop login, and re-pairing or re-login rotates the gateway token in place against it — the hostname is a display label only:

Rotation is graceful and self-healing (BR#3787) — a gateway is never killed by a rotation and never needs a manual re-pair:

  • Identity law: provisioning and pairing match tokens by the principal only. A label held by another device's credential is a collision — self-provision mints a suffixed row, human pairing refuses with a named error. Rotation never steals another device's token (the 2026-10-27 fleet 401 ping-pong was exactly this theft).
  • 72h grace: when a token rotates, the previous hash keeps authenticating for 72 hours, flagged stale. Rotation is a re-vend signal, not an amputation — in-flight dispatches finish. Admin revocation still kills both hashes instantly.
  • Negotiated self-heal: every authenticated response carries x-bridge-token-epoch; the runner re-vends its own row proactively at the next tick boundary when the epoch moves (POST /api/token/repair, agent plane — the principal is the credential, so headless gateways self-heal without a human session). On a hard 401/403 the runner classifies the failure and runs the self-heal ladder — keychain reload (another process may have re-vended), principal repair, desktop self-provision — with exponential backoff (5s doubling, 300s cap), and every repair failure reason is logged, never swallowed. Auth failure is never terminal while desired state is "running" (BR#4113): there is no logout-and-halt path left in the loop. If the ladder stays blocked the gateway PARKS LOUDLY — a needs attention banner in the app (and needs_attention in ~/.bridge/dispatch/runner.json) — while it keeps polling and retrying on the capped cadence, resuming dispatch on its own within one poll interval the moment the hub accepts the device again (grace window, rollback, row restore) — zero human action. Only the identity-law gate (a saved token that is not gateway-class) signs the desktop out, and only a lost/revoked principal forces a human re-pair; an admin revoke parks the gateway loudly (it claims nothing) instead of stopping it silently. Repairs are rate-limited per token (6/hour) so a crash loop can't mint infinitely.

Owner columns render labels, never anchors (BR#4262) — the owner stored on a card is the principal (the matching anchor); every display surface (board, orient, get_artefact, my-tasks) resolves it through a render-time join to the owning token's label, falling back to the stored value verbatim when no token row matches (legacy system:* owners, hand-minted labels). Matching never uses the rendered label. And the namespaces never impersonate each other: new principals are vended dev_-shaped (generatePrincipalId — a principal can never be mistaken for a bkw_ token; existing principals are not migrated), and every label door (token.rename, pair approve, agent.provision, human mint) rejects bkw_/dev_-prefixed labels — the 2026-10-28/29 incident was a token-shaped string pasted as a label that then rendered into owner columns.

Machine/agent names are display, not identity — renames alias, never mint (2026-10-29): a hostname change (macOS local-name rotation, the network's -2 dedup suffix, a VPS's DHCP IP, a lost principal file) used to mint a fresh gateway identity and — because agent labels are namespaced <gateway>/<agent> — a fresh identity per provisioned agent, piling dead labels on the board. Now every rename through the principal anchor records the old name in identity_aliases (old → current), and:

  • agent.provision under a renamed gateway renames the agent token in place (same id, same hash — the runner's stored secret keeps working) instead of minting <newGW>/<agent>;
  • the identity list (/agents) collapses each identity to its live label and carries the retired names as aliases (gateway→agent edges resolve across the alias set too), so history reads through one name;
  • admin.alias {alias, canonical} merges the legacy dead labels that predate the principal anchor (human asserts the mapping once; admin.aliases lists the table; a retired name can never be a canonical, so chains stay forward-only).
{
  "schema_version": 2,                 // written by the runner; future versions warn
  "url": "https://bridge.<your-subdomain>.workers.dev",
                                       // no "token" — the keychain owns it
  "workdir": "/path/to/repo",          // repo the runner works in
  "max_sessions": 1,                   // concurrency: concurrent agent sessions (WIP limit)
  "backends": {                        // provider sections — each carries its own config
    "opencode": {                      // model optional: unset = opencode's own default config
      "model": "openrouter/~anthropic/claude-haiku-latest",
      "agent": "reviewer",             // optional agent profile
      "bin": "/home/me/.local/bin/opencode", // CLI binary override — ABSOLUTE path strongly
                                             // recommended under systemd (minimal PATH)
      "mode": "acp"                    // transport: "acp" (default, live session updates) | "stdio"
    },
    "kiro": {                          // profile-driven: the kiro profile pins provider+model
    }
  },
  "report_minutes": null,              // null = agents report like a human (at natural milestones);
                                       // number 1-60 = fixed heartbeat cadence, in minutes
  "poll_secs": 60,                     // how often the runner polls the board
  "timeout_mins": 30,                  // per-session kill switch
  "isolate": true,                     // per-task git worktrees + PR branch (default true)
  "worktree_exempt_repos": []          // repos where per-task worktrees are skipped
                                       // (webapp-only checkout class); env override:
                                       // BRIDGE_WORKTREE_EXEMPT_REPOS (comma-separated)
  "identity": {                        // git commit/signing identity for dispatched
                                       // sessions (BR#3185). Omitted = the built-in
                                       // worker class (detached-cody per BR#3181).
    "name": "detached-cody",
    "email": "297316395+detached-cody@users.noreply.github.com",
    "signingKeyPath": "~/.ssh/sv-agents-signing.pub",
    "signingProgram": "/usr/bin/ssh-keygen"
  }
}

Identity profiles (git signing, BR#3185)

Dispatched sessions commit under a first-class identity profile — not ad-hoc -c overrides. At worktree provisioning the runner writes the profile into repo-local git config on the runner's own clone (the config that clone's worktrees share — never --global: user.name, user.email, user.signingkey, gpg.format=ssh, gpg.ssh.program, commit.gpgsign=true); your global git config is never touched, so the shared checkout keeps its own identity.

  • Built-in class worker: every dispatched session defaults to detached-cody / 297316395+detached-cody@users.noreply.github.com, signing with ~/.ssh/sv-agents-signing.pub via plain /usr/bin/ssh-keygen (the BR#3181 ruling — no 1Password in the agent path). The values are the recorded contract packages/contracts/fixtures/identity-profiles.json; the runner fails its own test suite when the built-in drifts from it.
  • Precedence: an explicit identity block in the config beats the built-in class; the built-in class beats whatever is in the host's global git config for sessions dispatched into worktrees. An agent overriding user.* ad-hoc (-c) drifts the committer identity away from the class — the verify gate bounces that.
  • Validation leg: provisioning ends with a scratch sign check through the profile's own program and key (ssh-keygen -Y sign -n git). A missing key, wrong program, or hung signing program refuses the dispatch loudly (like the smoke gate) — the task is noted on the board and the runner retries next cycle; unsigned commits are never shipped.
  • Board surfacing: Connect nests each gateway's provisioned worker agents under it with their identity class, and the pairing screen states the class the device declared plus what its runner's dispatches commit as (BR#2455 grouping, reused).

If the hub rejects the token (401), the runner attempts one automatic repair (re-vend via the live login session) and, failing that, logs out loudly with a human-readable reason — the token never silently vanishes.

If no agent CLI is dispatchable, the runner says so loudly every tick and exits non-zero once in-flight sessions drain — systemctl status bridge shows the failure and Restart=always (with backoff + jitter, BR#4682) revives it; a non-dispatchable agent CLI is a fix-it-then-restart situation, bridge doctor diagnoses. Under systemd, use absolute bin paths or a PATH override for the unit: the service does not inherit your shell's PATH.

Notes:

  • Per-provider config: models live in the models map keyed by provider — opencode needs an explicit model (the runner refuses to dispatch without one; the CLI's own default is how a stale pin silently downgrades a fleet). Kiro is profile-driven, so it needs no model entry.
  • Identity: gateways and agents share one label namespace with a class boundary — gateway tokens are infrastructure labelled with the hostname slug (e.g. macbookpro) and tagged with the gateway class; they are never task owners, and agent labels (opencode-ray) never take the gateway tag. The token (its principal) anchors identity: the class is the gateway tag on the token, never a name convention — labels are free-form display and nothing gates on a label shape (a label held by another device's credential is a collision, not a naming violation). Connect shows gateways with the agents they dispatched, attributed agents nested under their gateway. Dispatched work is attributed to the agent the gateway dispatched as, named <hostname>-<backend> (e.g. macbookpro-opencode) or overridden with "agent_name" in the config. Legacy tokens keep their labels — the boundary is enforced by the gateway tag on the token, never by the name.
  • Model cost: whatever you set here is what every dispatched session uses. Check the opencode CLI's own config (~/.config/opencode/opencode.json, "model" key) if spend looks high despite a pin here.
  • Reporting: the runner relays each agent's real stdout lines to the board as it works, so narration = liveness. Set "report_minutes": 5 only if your agent produces no narration at all.

4 · Connect agents (MCP)

Agents join the fleet by pointing their MCP config at the hub. The config format is per-client — the Connect tab's setup modal emits the right snippet with your token slotted in.

opencode — add an mcp entry to ~/.config/opencode/opencode.json (note: the key is mcp, not mcpServers — opencode silently ignores the latter, and an agent with no bridge tools will waste its run hunting for a CLI):

{ "$schema": "https://opencode.ai/config.json",
  "mcp": { "bridge": {
      "type": "remote",
      "url": "https://bridge.<your-subdomain>.workers.dev/mcp",
      "enabled": true,
      "oauth": false,
      "headers": { "Authorization": "Bearer <token from the Connect tab>" }
} } }

oauth: false matters for headless use: without it opencode tries a browser OAuth flow when the hub returns a 401, which hangs a dispatched session.

Generic mcpServers clients (claude code and similar):

{ "mcpServers": { "bridge": {
    "type": "http",
    "url": "https://bridge.<your-subdomain>.workers.dev/mcp",
    "headers": { "Authorization": "Bearer <token from the Connect tab>" }
} } }

Verify with opencode mcp list — bridge should list the board tools. Config is merged per-server-key, so the bridge entry coexists with any other MCP servers the host already uses.

Dispatch hosts: the runner spawns opencode run as a child process, so the agent CLI resolves its own ambient config — a dispatch host needs this entry for whichever CLI the runner drives, or dispatched sessions start toolless.

Mint agent tokens in the web UI under Connect. Agents call orient first — it returns the protocol card, open work, decisions awaiting consensus, and the agent's own behavioural score. Every artefact row an MCP tool returns carries url — the card's canonical deep link (https://<hub>/card/BR-3990) — so an agent citing a card can hand a human the link that opens it (BR#3990). Emitted links always use the canonical origin — the CANONICAL_ORIGIN var (default https://hub.fleetbridge.run; set it to your own domain when self-hosting on a custom domain), never the ingress host a request happened to arrive on, so a hub reached via its fallback hostname still hands out canonical links (BR#4695).

Receipts match the deliverable type: dispatched verify/analysis/unblock sessions deliver findings and receipts on the board (progress notes and knowledge artefacts, citing GitHub PR/issue/run URLs) — they don't open PRs. Only code work opens a PR. A URL that isn't a GitHub PR/issue/run URL is not a receipt, and a dispatch run that was provider-blocked (no model, no endpoints) can never hand off as complete.

Agent lanes: roles & playbooks

Tokens are lane-tagged: role:analysis, role:impl, role:review. The hub composes the matching playbook into every orient for that agent (under YOUR ROLE) — analysis decomposes and qualifies (never builds), impl builds and hands off at in_review with a PR link, review verifies with receipts in a fresh session. Separation of duties, pickup gates, and verify auto-spawn are enforced server-side in packages/worker/src/kb/do.ts, not by client convention.

Verify is the merge authority; the merge train is the executor (standing order BR#3033, re-divisioned by BR#3516). The verify round reviews substance and records an accepted or rejected verdict — its accepted verdict is what licenses the merge; the mechanical merge itself is executed by the merge train (the next section): a credential-holding edge executor that runs the 5c gate and merges when the verdict covers the current head and checks are green. There is no separate human merge gate for a verified PR. The cycle is: impl works → rebases → pushes a mergeable PR → in_review; verify reviews substance (mergeability confirmed first — rule 5c); findings → bounce back to impl (in_review → in_progress, one round, N bounces — the same verify round re-enters, it never stacks); loop until the train merges and closes. Impl owns mergeability preparation (rebase before every in_review — a conflicting PR is the impl's defect, not the reviewer's); the train owns mergeability enforcement (a conflicting, red or unsigned PR is bounced with the exact defect — never merged silently).

Bounces name their kind, and the queue is durable (BR#4066/4067/4068): a train bounce is one of three kinds with different next owners — conflict (trial-merge files listed; impl repair leg), coverage (the live head isn't covered by the accepted verdict — no conflict exists; the card immediately re-enters review and a fresh verify duty re-checks the head, so the stamp-race class self-heals), or gate (the named mechanical defect). The hub stamps the kind, defect and files on the card and boosts it to priority 0 — a bounced merge-ready card is the board's highest-value work and must not re-enter at default priority and starve. A fresh verify accept on a non-conflict bounce re-queues the train automatically (no agent hands the card back). The coverage self-heal is gate-proof (BR#4494): its in_review re-entry note carries the PR link the review gate requires, so the demanded re-verify duty always spawns instead of the re-entry silently stranding the card in_progress. And machine silence-releases (watchdog sweeps) can no longer destroy queue state: a card the train owns (queued / awaiting-ci / bounced on a live PR leg), or one parked on a typed await_event or a bound ask, is never released to todo — the sweeps park it blocked with the typed proxy intact (the proxy owns its exit), and the machine-actor backstop refuses any release path that slips past them (BR#4067). Humans and owners still release freely. The hold-park itself runs only AFTER an atom check (BR#4282): The one exception is the bounce re-entry (BR#4494): a bounce re-opens its card in_progress with the prior — usually dead — executor session, and in_progress is not claimable; for a bounced card the train has already handed the leg back to the board, so when the re-entry sits silent past the stale window the sweep demotes it to a claimable todo (bounce brief and boost intact; result class bounce_reentry_demote, exempt from abandonment scoring) instead of re-parking it — queued/awaiting-ci keep the full hold. the hub holds no VCS credentials, so a pr_merged proxy is evaluated against BOARD receipts only — the train's own awaiting-ci stamp (with merge_sha) or the provenance row. A proxy that is already satisfied is never re-armed: instead of re-parking the card blocked, the sweep spawns the owned done-close duty — which, when the parent sits in_progress or in_review, closes ITSELF done first and then the parent (the exit flipped while the card worked; it owes a close, not a resume). The silence-re-park can also no longer loop: a flood-guard skip (skip_unblock_spawn with flood: true) is the dedup working and is audited without folding a breach card, and a resume consumes the unblock round so post-resume silence issues one fresh round instead of flooding for an hour. The spawner cap re-suppresses the same way (BR#4702): once the BR#2893 escalation stands, further capped attempts audit spawn_cap_skipped without folding a breach card — the escalation, not a per-tick breach card, carries the recurring block to the human ledger. The trains panel is honest about capability and activity (BR#4981): a held lock names its machine; an idle lane ticked by a merge-capable gateway inside the 15-minute window shows the pill train ready (the tooltip names the machine and its last tick age); only a lane with no capable tick inside the window says the muted pill no gateway; a gate in progress on the queue head reads attempting (with the head card and the last ledger action in the tooltip); a protection-blocked head reads ⏸ bypass wait; a retired lane reads retired. The lane row is three labeled time-frames (BR#5114, Ray 2026-10-30: "I want to know WHAT is being done right now and progress, what is up next, what is in the queue remaining"): NOW — the status pill + in-flight PR + phase age PLUS the holder's live last action verbatim from its tick log (the BR#4104 proof-of-progress seam, hub-consumed: meta.lane.now_action, rendered only while fresh against the 5-minute lease TTL — a held head SAYS why it is held, e.g. "PR #2588: head check RED: Meta-review nightly", instead of a bare gate pill for hours); NEXT — the first queued card + its PR; QUEUE — the remaining cards with their PRs and depth. The executor stamps the gate's Skip/Note verdicts into that seam (ctx.note on the gate outcome), the hub persists the latest renewal note, and the throughput rate + last completed event (with age) close the NOW column so "last" and "now" are never conflated again.

One card may carry several PRs (BR#3659): a verify close that accepts multiple PRs cites every PR URL with one head <sha> after each — PR <url> head <sha>; PR <url> head <sha> — and the hub queues one leg per PR (meta.prs; pairing is line-adjacency based: a head after ; belongs to the next clause's PR, never the previous one, and a cited PR without its head fails the queue closed with the card stamped unbound). A single-PR close keeps the pre-3659 shape (the note's one URL binds its first head <sha> wherever it sits). The citation itself is convention-tolerant (BR#4648): a PR cited as the board's own owner/name#N shorthand — the way every bounce note and brief writes it — parses like the URL (the URL form wins when both cite the same PR), and a head-bearing close with no PR citation at all refreshes a card that has exactly one open leg (multi-leg stamps stay fail-closed — a lone head is never guessed onto one of several legs). The re-queue note is honest about it: it says stamp refreshed only when a stamp actually moved, and an accept whose head <sha> cannot bind any leg is audited loud (queue_stamp_not_refreshed) instead of silently re-queuing a stale stamp — the silent-stale class that once spun coverage bounces on an unchanged head (five accepts on BR#3764 while the stamp never left the old head). The card closes only when its last leg lands — after a partial landing it stays queued with an honest reason naming the legs still out, and the train drives the next leg on a later tick. A bounced card is NOT re-queued by a stale verify close (the bounce stands until the card re-enters review and a fresh verify duty accepts the new head) — the close-note scrubbing workaround from BR#3651 is obsolete.

  • Hub defaults: DEFAULT_ROLE_PLAYBOOKS in packages/worker/src/kb/core.ts.
  • Per-hub overrides: admin settings → Roles & playbooks (settings.roles.*); clearing an override resets to the hub default.
  • Subagent prompt seed: the pinned card at packages/adapters/bridge-protocol-card.md.
  • Full contract: docs/roles-and-playbooks.md.

On tasks, roles live in a dedicated roles column — create_artefact { roles: ["review"] } — never in tags. tags are 100% free-form user/agent labels (themes like gtm, deploy-train, pricing): they render as chips and drive the filterable theme bar in Top and Backlog, but they never change system behaviour — tagging a task role:review does nothing. Questions are their own artefact type (type: "question"; asks create them automatically): open participation, answered via the ask band or progress entries, closed to done/superseded — never claimed.

Humans can hold a question (BR#3911): on any open question card, Claim — I'll handle it (web + desktop, human session only) stamps a 10-minute hold (human_claimed {by, ts, expires_ts}) that dismisses the question from the agent pool — agent inboxes drop it, orient says in hand by <you>, and agent contributions get a stand-down postscript. Claim ≠ answer: protocol 6b stands, the question still closes via your ADR. Answer or close before the window and the timer cancels; no-show and the watchdog sweep auto-reverts the hold, leaving an expiry note on the card (honest churn, never silent). Release puts it back early; agent claims on questions stay forbidden, and the runner claim path is untouched (the hold is a meta stamp, not a transition). Window: question_claim_minutes setting (default 10).

The ask contract: asks are question nodes

ask_agent writes no side table — the ask is a question task: a BR#-numbered type: "question" node, child_of the subject task when you pass artefact_id. The subject goes blocked, the watchdog pauses for it, and an answer auto-resumes it back to in_progress — exactly once: the ask is the unblock path, so an ask-bound block spawns no separate unblock/sweep duty that could race the answer (the card carries an awaiting_ask marker the machine's spawn gate reads; answering clears it). But only onto a live holder: the resume checks the holder's real liveness signals (session-alive vouch, last heartbeat, session/owner ping) against the same stale window the watchdog uses, and a dead holder (no live signal, ask older than the window) is released to the pool for fresh dispatch instead (BR#3819) — an ask can never resume work onto a session that already finished. Three fields are the whole contract, enforced server-side (askCreate / The fields are the whole contract, enforced server-side (askCreate / askAnswer in packages/worker/src/kb/do.ts):

  • question — the summary. One answerable sentence ending in ?, ≤240 chars; it becomes the node title. Status reports are rejected at the door — receipts and narrative go in details.
  • details — the plain-reading brief. Mandatory on human-directed asks (rejected without it): what the subject is, the current state, why it needs a human — every BR#/PR# reference glossed in one clause. An ask must be answerable without opening another card.
  • reason — the why_escalated. Mandatory on human-directed asks (rejected without it): one sentence on why this is escalated instead of automatic — the gate, credential or authority it waits on. ≤1200 chars; renders as the ask's one-line why (never chopped mid-clause — the full story stays in details).
  • options — the decision. 2–8 one-click answers (rejected with fewer): the recorded click is the answer, and it closes the question done. When the asker recommends an option it is first and prefixed (Recommended) — the first button is the recommendation.

Asks carry ordinary BR# ids, so refs linkify wherever BR# refs render (cards, task detail — the ref scanner in packages/ui-kit/src/views/refs.ts) and resolve via get_artefact like any artefact. answer_ask takes the question node's BR# id — there are no separate ASK-* artefact ids. Humans answer in the app: the ask band in Top (web + desktop) renders every open ask with its options as one-click buttons, every ask-band row (verdict, decision, node ask) shows its own BR# id as a mono chip next to the rel time — you always know which ask you are acting on — and each node-ask row ends with a type your own answer… row that opens a text input: the typed text is recorded with the ask exactly like a click (BR#3912 — an option that promises a rewrite, like "RE-SCOPE — I'll rewrite the card myself", needs somewhere to write it). Typed answers follow the same dispatch law as clicks: an answer starting with a RE-SCOPE/KEEP option verb keeps the subject parked, RELEASE/CANCEL releases it to the pool, SUPERSEDE drops it, anything else resumes it. urgency is an ask vocabulary, not a sentiment: "time-sensitive" renders the urgent NOW chip in the ask band; anything else is normal — agents can no longer file an "urgent" ask that renders as a quiet one.

Asks to offline agents escalate to you (BR#3942). An ask addressed to an agent identity that sits unanswered past ask_escalation_minutes (a setting, default 30, clamped 5–240) while that identity is offline — no direct token or gateway-dispatch edge seen in the last 10 minutes, or never seen at all — is escalated by the watchdog to the human planes: the same ask node is stamped escalated (one re-route, never a duplicate ask), the ask band and For Me render an ESCALATED — addressee offline chip with the proof attached, and the row shows the addressee's presence (offline · last seen Nm ago / never seen) so a stalled card tells you who it is waiting on and whether that identity is even alive. The original options stay on the ask and answering it resumes the blocked subject as usual. Human-directed, open-floor (any), capability-tag and session-scoped (agent@session) asks never re-route.

Human comments post from every web surface. The comment boxes (task detail's "Comment / steer", the decision popover, discussion replies) all write to the card's history through the same door agents use — the web plane maps POST /api/kb/progress to the knowledge object's progress.log op, so a comment from any surface lands on the card instead of 404ing — and a failed post shows the server's message, not a bare "comment failed". The task detail panel loads the card, its history and its related tasks in parallel: each section renders as its own data arrives, carries its own error + retry, and the non-essential related list times out to nothing instead of holding the panel hostage. Every section races its own timeout — the card and history get 15s, related enrichment 5s — so a stalled fetch answers with a named error + retry (or an explicit loading spinner) and never leaves the panel a bare shell.

Claim locally — next to Reframe on every open ask (both surfaces: the ask band rows in Top and TaskDetail's AskSelfCard), a Claim locally button copies a ready-to-paste takeover prompt for a local interactive agent (e.g. an opencode/claude session on your machine): the target card (id, title, status, owner, part_of, github ref), the card body (acceptance/spec) plus the latest progress note, ask context when present (reason, options), and the full takeover protocol — orient with a unique session, verify state first-hand (work may be partially done), claim via transition, heartbeat ≤10 min, shared-checkout discipline (worktree off fresh origin/main, explicit-path staging, never git add -A), commit+sign as detached-cody (canonical invocation inline), finish = PR + in_review, verify separation, human legs escalate to you. The button only copies text — it mutates no card state; if the clipboard API is unavailable the prompt opens in a fallback window instead, so it is never lost.

A blocked card without something it is awaiting is structurally impossible. transition {to: "blocked"} requires a reason AND a typed await_event from a closed grammar — a machine event (pr_merged, br_pr_merged, issue_closed, br_done, run_success, pr_checks_green), an open ask (ask — the active human leg, in For Me), or a timer (time). Verdict-duty tasks (role:review / role:unblock) are refused at this door entirely — they can never park: their silence is declared with log_progress grace, and "cannot proceed" is itself the verdict (BR#4392). Free text (unblock_condition) is optional human-friendly annotation, derived from the structure when absent; text that parses cleanly to one atom is auto-typed at write time — and that includes the ask/time legs (BR#4062): the machine-proxy prose convention (Machine proxy (typed await_event ask ask_id=BR#N): …, (typed await_event time until_ts=<epoch ms>) — ISO timestamps normalize to epoch ms) parses to the same typed event the await_event param carries, and the gate's rejection names the param when nothing parses. Conjunction conditions parse whole (BR#3986): every BR#N done / PR #N merged / GH#N closed on a line yields its atom, and the proxy watch flips only when every atom evaluates TRUE — never on the first alone. Two park shapes are refused loud at the door: text that parses to multiple atoms (a park awaits one typed leg — name it in await_event and carry the rest as prose; multi-atom text never self-migrates, breach class gate:multi-atom-condition), and a self-referential await — a card naming its own done (br_done/br_pr_merged on itself) can never fire while blocked, the BR#136 deadlock (breach class gate:self-referential-await). The hub's proxy watch re-checks machine events every 15 min and spawns the unblock duty on the flip; an answered ask auto-resumes its subject (live holders only — a dead holder is released to the pool for fresh dispatch, BR#3819); an elapsed timer fires the duty — prose-derived ask/time parks self-migrate to the typed event and evaluate first-class like any typed park. Legacy prose-only cards run the disposition ladder (decideProseDisposition, BR#4416 — the machine resolves what it can prove from the card's own state, and a human is asked only for genuine ambiguity, never one ask per card): a park whose machine state already holds the exit proof (a provenance/train-ledger merge row, an answered bound ask, a terminal root blocker) auto-flips per its own promise — no ask exists (the BR#4170 class: a PR sat MERGED while a human was asked to re-scope); a park with a recoverable typed leg (meta PRs, train stamps, an open bound ask, an open root blocker) is re-typed in place and re-enters the normal typed path; a park with no machine leg at all is released to todo for re-scope (the BR#3578 no-leg law — the churn guard's park is not a human decision), except runner/human-owned or human-qualified cards, where supersede-vs-continue is genuinely yours: those collect into ONE class ask per day with each card's facts and consequence-naming options. Every sweep pass emits prose_blocked_count (visible in /stats and the prose_sweep audit line); after the BR#4248 generator-kill has merged and deployed, a nonzero count is an incident — ONE deduped alarm at the sink naming the cards and the writer that minted them, never per-card asks. Answering the class ask actions every card it names: RELEASE/CANCEL → released to the pool, SUPERSEDE → dropped, RE-SCOPE → each card re-blocks with a typed await (the answered ask is the record). The SUPERSEDE drop is the one blocked → superseded edge (BR#3913): it exists exactly for the answered ask — the answer is the authorisation — and for the web; a bare agent session cannot take it (transition {to: "superseded"} from a session is refused by the supersede-authority law row), and a drop closes the card's open unblock/verify rounds. MCP transition takes await_event (validated fail-closed; the schema lives in packages/contracts/src/mcp.ts, the grammar in packages/worker/src/kb/closure.ts). Two park shapes are refused loud at the door: text that parses to multiple atoms (a park awaits one typed leg — name it in await_event and carry the rest as prose; multi-atom text never self-migrates, breach class gate:multi-atom-condition; the refusal names every atom in short form so the full list survives the audit trail's 300-char reason slice, BR#4303), and a self-referential await — a card naming its own done (br_done/br_pr_merged on itself) can never fire while blocked, the BR#136 deadlock (breach class gate:self-referential-await). Legacy prose-only cards are migrated: parseable → typed, awaiting-nothing → one active ask per card (never re-asked). Answering a migration ask actions the card: RELEASE/CANCEL → released to the pool, SUPERSEDE → dropped, RE-SCOPE/KEEP → a deliberate human-owned park (the answered ask is the record). Two park shapes are refused loud at the door: text that parses to multiple atoms (a park awaits one typed leg — name it in await_event and carry the rest as prose; multi-atom text never self-migrates, breach class gate:multi-atom-condition; the verify duty's ACCEPT step names the train park's typed leg explicitly (await_event {event:"pr_merged", repo, n} rides the merge-train: queued transition, BR#4425), so the standard queue path never trips the door), and a self-referential await — a card naming its own done (br_done/br_pr_merged on itself) can never fire while blocked, the BR#136 deadlock (breach class gate:self-referential-await). Free text (unblock_condition) is optional human-friendly annotation, derived from the structure when absent; text that parses cleanly to one atom is auto-typed at write time. The hub's proxy watch re-checks machine events every 15 min and spawns the unblock duty on the flip; an answered ask auto-resumes its subject; an elapsed timer fires the duty. The server holds itself to the same law: the churn-guard redirect (a card released to the pool 5 times, or 5 silent watchdog cycles) parks blocked stamped with a typed 24h time cooldown — the timer is the machine exit (on fire the unblock duty re-checks first-hand; a proven release resets the attempt counter), and the read-the-history text stays as annotation only. A post-condition invariant (blocked-await-invariant) refuses ANY internal blocked landing without a grammar-valid await, so the server can never mint the prose-only "awaits nothing" parks that force per-card human escalation asks (BR#4248). Supersede — next to Claim locally in the ask action row, and beside Unblock now on every blocked card (and on unclaimed todo cards): a Supersede… button retires a dead card in one click (BR#3910). The dialog shows the card's current status and takes an optional replacement task id (BR#<n>, #<n> or a bare number, validated before submit). The server composes the documented two-step itself — blocked → release → todo → superseded (the BR#1852/BR#1935 precedent) — so the raw intermediate states never surface; the card lands superseded with superseded_by plus a clickable replacement link, and the supersedes relation records the lineage machine-readably. Supersede is web/human-only (BR#2480): the route refuses gateway bearers with 403 and the agent-facing transition gate still has no →superseded edge, so no session can execute it. Every refusal surfaces the server's message inline and in the banner with the card left unchanged — a silent no-op is the defect class this button exists to kill (BR#3649's answer-before-click misfire, BR#3905's timer-spin churn on dead-premise blocked cards). Dead-premise cards whose premise can no longer hold are closed for good by the human-only Supersede button instead of looping through re-check duties (BR#3910).

Destination-green: the stop-the-line gate

Merges must not land while the destination branch is red — accepted decision BR#3009 makes that law, and BR#3008 makes it mechanical instead of discipline. Three halves, same rule:

  • The block: every PR runs the required main-green gate check (.github/workflows/main-green-gate.yml + scripts/main-green-gate.sh). It reads the check runs on main's HEAD (gh api repos/<repo>/commits/<sha>/check-runs): any failure (or a check still running, or no check runs at all) fails the gate and GitHub refuses the merge via the main: main-green gate + substance checks required ruleset. The newest run per check judges; a cancelled run says nothing. The one exception is written into the gate: a PR green on the exact checks main fails passes with a printed REMEDIATION CARVE-OUT receipt — BR#3009's remediation-merge path, expressible and receipted, not a loophole.
  • The substance: the block judges main's HEAD; it never judged the PR's own head — the hole #174 sailed through (merged with Test + CLI build (packages/cli) FAILURE on its head while main was green; main went red and the #211 hotfix followed). The same ruleset now also requires Test and CLI build (packages/cli) green on the PR's own head, with strict_required_status_checks_policy on: the checks must come from the latest head, so a stale green never counts. A red-head PR cannot merge even while main is green — the train's gate (decideTrainGate, composing the standing decideMergeAction in packages/worker/src/kb/core.ts) enforces the same law: two reds never merge. Deliberately NOT required: the Superpositional review (a failing review is a quality issue and merge-allowed — a required check would deadlock against it) and the BR#2267 externals (Workers builds / deploy previews never gate). The bypass actor is unchanged — rayh alone keeps bypass_mode=always: the human retains the loud escape hatch; machines do not. Proven end-to-end by probe PRs #216 and #217.
  • The stop (executor-fed, BR#3516): the hub holds no VCS credentials and no longer reads GitHub — red detection is fed in by the merge-train executor: its awaiting-CI poll posts the merge commit's check runs (train.ci), the hub classifies them (classifyRequiredRuns — red, or unknown past 3× the lane's typical CI time, fail-closed), and any non-green verdict stops the line: the lane card reads merge-train: STOPPED — … (resume when the line is green), the just-merged culprit card is bounced to in_progress with the failing runs appended (red after merge), and ONE time-sensitive ask is filed per red episode to the ALARM SINK card (train_alarm_sink_id setting, default BR#3124) — deduped per sha by the identical-open-ask guard, so ten red runs are one ask, not ten. Options ride the ask: Ack — on it / Revert the culprit / Waive — resume the lane. Green again → the executor's next CI poll feeds a green classification (train:landed) and the hub releases the stop automatically. v1 stops the line; there is no auto-revert — fix forward or revert is the culprit card's job. Work on branches (worktrees) continues — only the line stops. Two BR#5061 refinements keep the stop honest instead of stormy: one ledger episode per stop (the same sha + reason re-reported by a tick loop — the stop arm clearing on green main while the awaiting-CI arm re-fires it — is a train:stopped_deduped no-op, never a new ledger row every 20 seconds), and the phantom-required carve-out (Ray ruling 2026-10-30: "if it can't merge, bounce it and move onto the next one — the train goes regardless"): past the fail-closed bound, if every unsettled required workflow is MISSING (no check run ever existed for it on the merge commit — e.g. a PR-context-only gate that only runs on pull_request) while at least one required workflow proved green on that very commit, waiting cannot change the verdict — the merge LANDS judged on the workflows that exist, the never-run names are dropped with a loud audit, and ONE config-defect ask per episode goes to the alarm sink (fix required_workflows); a genuinely stuck OBSERVED run (or an empty, zero-evidence poll) keeps the fail-closed stop — the carve-out never fires on no evidence.

The root-fix fast lane (BR#5190, steering BR#5189)

A stop-the-line episode now spawns its own cure, and the cure jumps every queue. When the stop fires, the hub ensures ONE root-fix card for the red — tagged root_blocker + main-red + p0, role impl, a child of the lane relation — binding the culprit facts (repo, culprit head_sha, base, why, the failing checks, bound_at), with repair legs riding it as they open (pr stamped on the binding). Three paths, never a duplicate: the culprit card is still open and qualified → it is re-bound in place; another open card in the same repo qualifies → the binding moves to it; nothing qualifies → a fresh card spawns. The red and the repair share one identity: the card the train was stopped for IS the ticket that fixes it.

The queue sorts in four absolute tiers — root_fix > boosted > critical > normal — root-fix beats even a human ▲ boost (the old boosted meta label critical is renamed accordingly), and a red base halves the walk further: when the lane's base is red the queue offers ONLY tier 0 — every other head parks until main greens again (two reds never merge, and queuing green heads behind a red base just rehearses the block). While the line is stopped, the executor's stop arm falls through to the drain walk ONLY when the queue head IS the cure — the bisect stays parked while the fix is in flight — and the post-stop residue arm keeps its stop-null guard, so a cleared stop can never re-arm from residue.

At the gate the cure fast-tracks: a queue head that is a root-fix card, with the base red and its own head green, is not blocked by the base — the gate merges it and stamps the receipt ROOT-FIX FAST-TRACK (BR#5189) (the merged train event carries root_fix: true and a note suffix, so provenance shows the lane was cured, not blocked). One circular case cannot self-serve: when the base's red is the deploy-health alarm itself — the gate is judging the check that reports drift while the drift is the breakage — the gate files ONE human ask, "Merge the cure now" / "Not yet — hold the line", with a title byte-identical in the Rust executor and the TypeScript hub (one implementation shared as fixture tables, decideTrainGate) so the identical-open-ask dedupe matches whichever side fires first; every other red-head fall-through stays under standing law. Verify duty on a qualified root-fix card (tag + a stamped PR) names the fast lane in its body, so the reviewer knows the merge path is pre-cleared before they accept.

The deploy drift alarm's settle window (BR#3289)

The Deploy drift alarm check (deploy-check job in .github/workflows/ci.yml, probe logic in scripts/deploy-drift-alarm.sh) compares the live hub's __version commit against every main push. The hub deploys asynchronously (git push → CF Workers Builds), so a first-probe failure read "main is broken" on every push — 6+ false reds in 24h, each also tripping the main-green gate on the next PR. The alarm now settles first: while the hub still serves an older build it emits non-failing ::warning:: annotations for up to 10 minutes (SETTLE_WINDOW_SECONDS, default 600) and goes green the moment the hub serves the pushed SHA — or a newer main tip containing it (a faster merge superseding this push's deploy is not drift; drift is "deployed hub ≠ main tip"). Only a window that elapses stale fails loudly — real drift (CF Builds stalled or bypassed), feeding the main-green gate and the merge train's base-green leg exactly as before — and so does a hub that never answers. A failing main-tip lookup never turns a mismatch green (fail-closed). Guarded by npm run test:deploy-drift-alarm.

The merge train: serialized merges, credential-free hub (BR#3516)

Accepted decision BR#3516 re-divisioned the merge machinery: the hub holds no VCS credentials, ever. Every GitHub fact is fed to it by an executor running on your own machine; the hub decides (lease CAS, classification, events, queue) and only the executor acts (gh). The hub-side sweeps that read GitHub — the standing merge-queue sweep (BR#3198), the PR-disposition executor (BR#3312), the provenance sweep, the GitHub-reading closure audit — are deleted, not deprecated: a hub that cannot reach GitHub cannot lie about GitHub. The pure decision tables survive, pinned (packages/worker/test/merge-sweep.test.ts); the executor's gate (decideTrainGate → decideMergeAction) is one implementation shared with the hub, unit-pinned in packages/worker/test/merge-train.test.ts.

The executor

Since BR#4104 the executor ships inside the gateway (Ray-directed completion of the original BR#3516 spec): the daemon — the desktop app and the headless bridge runner share one dispatch core — spawns the train tick beside its dispatch loop and contends the lane lease exactly like the standalone command below. First-to-lease wins per lane; a holder that dies lapses its 5-minute lease and the next gateway reconciles from live GitHub state. No hand-installed systemd unit, no separate binary to update: the train updates ride the normal gateway deploy path, and every gateway on a stock build is an opportunistic executor. The identity law is enforced at the write boundary (BR#3181): the tick probes its gh identity class — merge / update-branch / close legs execute only under the worker-class credential (detached-cody); a personally-authed gateway reconciles and reports read-only, then stands down LOUDLY from write legs and releases the lease. Lease renewals carry proof-of-progress (last ledger action + age), and the hub consumes it (BR#4129): a holder whose action note freezes while cards queue is evicted — see the lease law below.

Capability honesty (BR#4489, ADR BR#4487): a gateway that cannot merge does not pretend to serve the lane. bridge doctor classifies the host (gh api user against MERGE_TRAIN_WORKER_IDENTITIES) and checks push permission on the lane repos; an incapable host prints will not claim train lock as I cannot merge PRs and doctor exits with the merge-train leg flagged (non-fatal for non-train functions). At claim time the executor re-checks the classification on a ≤5-minute cache — startup truth is not runtime truth — and a non-capable host skips lane contention entirely (the skip logs once per 10 minutes, not every tick). If a merge write is refused mid-flight (403 / permission), the executor releases the lease immediately and self-demotes for the process lifetime — it will not claim the train lock again until the gateway restarts with a fixed credential. Leases carry merge_capable, so the hub can tell a served lane from a pretended one.

bridge train remains the standalone form of the same module (decision BR#3880 — install is the unit: no Node, no script file). It runs on any machine that has gh (authed, with the repo scope), git and a clone of the target repo — or, with no MERGE_TRAIN_WORKDIR, it manages its own clone cache (~/.bridge/train/<owner>/<repo>) like the daemon leg does. The gate is one LAW with two fixture-locked implementations (decideTrainGate: TypeScript hub-side, Rust executor-side — mirrored test fixtures pin them in lockstep):

cd /path/to/repo-clone                 # the repo the train drives (MERGE_TRAIN_WORKDIR)
export BRIDGE_URL=https://bridge.<your-subdomain>.workers.dev
export BRIDGE_TOKEN=<token from the Connect tab>
~/.local/bin/bridge train          # long-running loop (45s ticks)
~/.local/bin/bridge train --once   # single tick (cron-friendly)

Env (standalone and daemon leg alike): MERGE_TRAIN_HOLDER (lease label, default merge-train@<host>), MERGE_TRAIN_INTERVAL_SECS (default 45), MERGE_TRAIN_METHOD (merge | squash | rebase, default merge), MERGE_TRAIN_WORKDIR (the clone; default cwd standalone / managed clone cache in the daemon), MERGE_TRAIN_LANES (comma-sep repo@base allowlist; default every lane the hub reports), MERGE_TRAIN_WORKER_IDENTITIES (worker-class gh logins, default detached-cody), MERGE_TRAIN_EVENTS=0 (kill switch for the event-driven wake leg), MERGE_TRAIN_ENABLED=0 (kill switch for the daemon leg). Flags: --lane <repo>@<base> (drive one lane only), --once, --interval <secs>, --method <m>. bridge doctor gates the prerequisites (gh present + authed + repo scope, git — each failure names the fix); the executor re-probes the cheap ones every start. It merges as whatever identity that gh is authed as — the identity gate refuses the write legs unless that is the worker-class credential per BR#3181, never a personal one.

Dedicated merge node (role: train, BR#4489): a normal gateway in a named mode — set "role": "train" in the config (~/.bridge/config.json) or BRIDGE_ROLE=train in the environment, and the daemon skips task dispatch entirely while the connection and merge-train legs keep running. This is the redundancy model from the ADR: run N capable gateways (or one dedicated train node) — merges never move server-side. Default (absent role) is the unchanged opportunistic gateway.

One tick: preflight → train_status → per lane: CAS-lease → reconcile from live GitHub (never trust a prior holder's picture) → awaiting-CI poll (train.ci; the hub lands, stops or pends) → else the derived queue is walked in order (BR#3935): a permanently unlandable entry — a CLOSED PR, a leg with no PR stamp, no unlanded leg on this lane — is logged and the walk advances to the next entry in the same tick (the card stays queued for the humans; the lane never starves behind it), while a transient no-op — mergeability still computing, unsettled checks, no verdict — still blocks; the first drivable entry's first unlanded leg on this lane (BR#3659: a multi-PR card rides leg by leg) → decideTrainGate → act: merge (gh) → train:merged → next tick polls CI on the merge commit. Every fact crosses the executor's fact boundary normalized (BR#3714): gh's GraphQL mergeable is the enum string "MERGEABLE"|"CONFLICTING"|"UNKNOWN" — never a boolean — and mergeStateStatus is UPPERCASE; prFacts() maps them to the boolean|null/lowercase the shared gate compares. Fed raw, the strings satisfied neither mergeable !== true nor === "clean", and every gated candidate bounced (genuinely mergeable PRs included) as "unmergeable — CONFLICTING".

Lanes, leases, queues

  • A lane card per repo@base — created on demand (train.lane), tagged merge-train + infrastructure, never claimable by agents, blocked at birth with the honest reason merge-train: awaiting an available gateway (merges happen when your machines are on). The card's blocked reason is recomputed by the hub from the card's own meta — idle / leased by X / attempting <card> (gate) (a queue head is in the gate; the tooltip carries the last ledger action) / settling on <sha> (PR #N) (naming the next head) / awaiting bypass merge — PR #N by <card> (a head blocked on branch protection a machine cannot satisfy — review/required checks; a human merge is the unblock and it self-clears when the PR lands) / STOPPED — … — culprit <card> / retired (a done/superseded lane card is never ticked again, BR#4977) / ready — last ticked by X Nm ago (a merge-capable gateway ticked inside the 15-minute window — the train will take work the moment it is queued) / awaiting an available gateway — so the board always shows the lane's truth, and an empty queue never hides which machine is driving the train.
  • Lane cards are close-protected + terminal cards never bind (BR#5061): the lane card IS the lane — closing one done/superseded retires a live subsystem. The 2026-10-29 outage: a duty session closed the state-vector/bridge@main lane card done as a close-reflex, the find-or-create kept binding the terminal card every tick, and the lane could not self-heal for hours. Two laws now hold: (1) the transition gate's infrastructure-close-protection rule refuses done/superseded on any card carrying meta.lane or tagged infrastructure unless the actor is the owner, a human (web:/human:), or the machinery (system:) — a duty reaching this edge gets a named rejection and records done-with-findings on ITS OWN card instead; and (2) the lane lookup skips terminal cards: find-or-create prefers the newest non-terminal card carrying the lane key and follows the supersedes edge to a pre-registered reseed — with the volatile in-flight state (an awaiting-CI merge, a line stop, a bypass wait) migrating to the live card once, loudly (lane_rebound), so an in-flight merge finishes its landing close across the rebind. With NO successor the lane is deliberately retired and the BR#4977 refusal holds (every op refuses lane retired; re-open the card or register a reseed to re-arm — BR#5061's spec'd create-fresh is superseded by the accepted BR#4977 decision on that path). train.status shows ONE row per lane — a retired card with a live successor is filtered as history, a retired card with no successor stays visibly retired (BR#4977) — and the hub's lane sweeps skip retired cards; the starvation clock never inits from epoch: a lane that has never recorded a merge-capable lease starts its starve clock at its own last status change (the bogus "starved 29888714m" alarm class is gone).
  • The lease is CAS with a 5-minute TTL: acquire → held across the whole queue (merge-train: leased by X — idle between items) → release. The hub stamps holder = the authenticated actor; the executor never supplies a holder, so a leaked token cannot forge another machine's lease. A lapsed lease is cleared by the hub's credential-free train-watch sweep and the next gateway reconciles from live GitHub — a crashed executor cannot wedge the lane.
  • Zombie eviction (BR#4129): every renewal carries proof-of-progress — the holder's last ledger action and its age. On a non-empty queue, a renewal whose action note is frozen past 10 minutes (TRAIN_PROGRESS_STALE_MS) is DENIED and the lease expires immediately, so the next gateway's acquire wins within one tick; the lane card shows zombie holder evicted — <holder> made no progress for Xm on a non-empty queue until a fresh holder takes over, and the eviction is audited (train:lease_evicted). A live holder never trips this: every legitimate tick on a non-empty queue either acts (a CI-poll note refreshes the action every tick) or releases the lease, and an empty queue always renews (idle holding is harmless). Legacy executors that send {} renewals carry no proof — treated as UNKNOWN (allowed + a loud audit line) while the deprecation window runs.
  • Starvation alarm (BR#4489): a lane whose queue is non-empty with no merge-capable lease for >10 minutes cannot advance — the executor outage class where gateways tick (bounces flow) but merges stand down. The hub stamps the episode (meta.lane.starved_since), broadcasts lane.starved on the WS push channel, marks the lane starved: {since, mins} in train_status (the trains panel renders it red), and files one time-sensitive ask per episode to the alarm sink — restart a capable gateway, merge the head by hand, or waive. Judged both on train.status reads and proactively on the hub's alarm sweep, so a dead train is loud even with zero panel readers. Recovery is automatic: the first capable lease (or the queue draining) clears the starved state.
  • Lane lifecycle: a retired lane is never ticked again (BR#4977): a lane card that is closed (done/superseded) — a base branch deleted after a drill, or a lane deliberately retired — is frozen. Every lane-touching hub op refuses it (lane retired — … re-open the lane card to re-arm): no lease acquire/renew/ release, no ready — last ticked stamp, no reason rewrite — the BR#4772 class where a card marked "never tick again" still showed ready — last ticked by … 0m ago ten minutes later. train_status shows the lane visibly retired (retired: true, empty queue, the reason names the retirement) instead of a phantom-ready row. The executor honors it from both directions: it skips any lane the hub marks retired, and it probes the lane's base branch before acquiring — a definitive 404 (base deleted on the remote) skips the lane with no lease and no hub meta writes, while any transient gh failure fails open. The derived queue is base-scoped (same law): a queued card rides only the lane whose base matches its PR stamp (meta.pr.base, defaulting to main) — a second lane on the same repo can never inherit another base's queue (the retired drill lane used to show @main's queue as its own).
  • Event-driven tick (BR#4806): when a lane's derived queue transitions empty → non-empty, the hub broadcasts lane.queued {repo, base, depth} on the push channel (SSE, the same transport as lane.starved — subscribe at /api/kb/sse with a gateway token). A ready gateway runs ONE tick on arrival — the exact same CAS lease acquire, identity gate (BR#3181) and proof-of-progress renewal (BR#4129) as every tick — so work that queues right after a tick starts within seconds instead of waiting out the next 45s poll. The poll cadence stays as the fallback: a missed event degrades to today's behaviour, and push + poll firing together is safe by construction (the CAS lease serializes; the second acquirer stands down — pinned in test).
  • The queue is derived, never stored: every open PR on the lane is re-read from GitHub each tick, oldest first, and only the head of the queue may merge — one merge in flight per lane, and the lane HOLDS until main settles before the next head enters. Serialized is the point: no interleaved merges, no surprise rebases, every merge attributable to exactly one lane event.
  • Back-pressure: main settles before the next head enters (BR#4804, cadence amended by BR#4934) — after a merge, the lane's awaiting-CI phase judges the check runs ON the merge commit: a lane with a configured required_workflows set settles exactly those; an unconfigured lane (the default) settles on every check run on the commit — build AND deploy — with a 2-minute settle dwell after the merge so slower-triggered deploys can register a check run. Anything unsettled (including a cancelled-only run) holds; anything red stops the line; only all-green (past the dwell) admits the next head. The old default — vacuous green for unconfigured lanes — merged back-to-back and every push cancelled the previous Deploy Dev, starving main. While settling, the lane reason reads settling on <sha> (PR #N): build/deploy in flight, attempting <card> next, heads held until main settles, and the panel's queue depth is the count of heads waiting behind the pressure valve.
  • Burst-drain settle: one settle per drain pass, not per merge (BR#4934, decision BR#4932) — the per-merge settle stays the law for a not-clean head, but a GATE-CLEAN head merges back-to-back behind the previous merge: the executor drains every gate-clean head in one pass (up to lane.burst_max, default 10, 0 = unlimited — it rides train_lane lane config alongside required_workflows) and runs the full settle ONCE, on the pass's FINAL merge commit. The lane reason names the phase mid-drain (merging burst (N merged, cap M) — settle pending) and on settle (settling on <sha> after N merges (PR #N): build/deploy in flight, heads held until main settles); without burst context the BR#4804 strings (BR#4981 vocabulary included) are byte-identical. Red anywhere stops the line instantly (GH#2261) — a red fed mid-drain never waits for the settle. While the settle wait runs, the executor uses it as a rebase window: one machine-clean update-branch per tick on a behind, verdict-covered queued head (a clean trial merge rides with it; a dirty one conflict-bounces; carries append contiguously via train.carry — the minimal primitive, superseded by L1.4's carry law when it lands). On a GREEN settle the pass's intermediate parked cards close on executor-fed git ancestry (ancestors_of_settled via train.ci — their runs were cancelled-by-next-push, and the hub holds no VCS credentials, so the ancestry is the evidence; zero hub GitHub calls); the final head closes per BR#4804 unchanged. On a RED settle the hub pre-stamps stop.bisect and the executor probes the pass's intermediate merge shas oldest→newest (one step per tick via train.bisect, re-running the stamped checks): the oldest RED probe names the ONE culprit — it bounces with a fix-forward brief + the BR#4068 boost while the wrongly stop-bounced final card re-parks to close on the next green settle; probes that stall past the CI bound (or leave nothing re-runnable) stamp the walk INCONCLUSIVE and the pass bounces oldest-first.
  • The landed close is guarded — a green verdict can never wedge the lane (BR#4753): when the all-green verdict fires the landed close and the close itself is refused (a gate rejection — e.g. done-open-children — or a storage fault), the old code raw-threw, silently, every tick: the lane sat awaiting-ci fail-open past the fail-closed bound (which was only ever consulted on the pending branch) and the queue starved behind it. Now every failure is counted on the lane's in-flight record (the lane reason names it: landed close FAILED N× (<class>)) and resolved by the retry ladder: a semantic gate rejection close-bounces the culprit card immediately (retrying cannot succeed — the bounce brief carries the named gate error and the repair contract: the PR is already merged and main green, so resolve exactly the named precondition, then close citing the merge commit — no re-merge, no re-verify); anything else retries ≤3 ticks, then — or past the bound, whichever first — bounces fail-closed with bounce_bound. The bounced card is boosted to priority 0 (one mechanical leg from landed) and leaves the derived queue, so the lane idles and the next head enters; a fault after the done actually landed repairs to a clean landed on the next tick instead of bouncing a closed card. One deduped ask per episode rides the alarm sink (train_alarm_sink_id, default BR#3124) — the failure used to be invisible.

The gate, the carry law, the bounce

decideTrainGate composes the standing decideMergeAction 5c gate (mergeable, clean, every commit GitHub-verified, base green, head checks green) plus verdict coverage: the accepted verify verdict must stamp the exact head, or arrive by a contiguous machine-clean carry chain:

  • A git failure is never a verdict (BR#3935): the executor fetches the PR head explicitly (refs/pull/<n>/head) before any local merge-base/merge-tree names it — a head authored on another box is a bad object locally, and merge-tree exits 1 for a bad object, the same code as a real conflict. Both inputs are verified commit objects first; any git failure fails the tick closed with the stderr, and a bounce's conflict-file list is exactly the conflicted files (--no-messages keeps git's prose out of it). The old behavior — a 128 folded into behind=true and a bounce with an empty conflict list — is dead.
  • Carry law (BR#3516 §5): the base advanced and the executor's gh pr update-branch lands machine-clean → the accepted verdict CARRIES (the hub appends the carry record on train:merged). A conflict resolved by hand means authored code changed under the verdict → the verdict is INVALID at the new head: the executor enumerates the conflicting files (git merge-tree), train:bounced lists them on the PR's card, the card re-enters review, and the train re-queues it at the BACK once a fresh verify accepts the new head. A bounce and its re-queue are machine-managed round trips: a train park — the train's own merge_train flag, or an agent re-queue following the verify contract's canonical merge-train: queued note — never spawns an unblock child. The train is the unblock path (BR#3516 §2); a spawned child would only be superseded by the next bounce, with the re-park after it flood-guarded on the stale stamp — one breach card per bounce cycle (specimen BR#3990 → breach BR#4135).
  • Bounced cards cannot starve (BR#4068) — a bounced card is the most valuable work on the board: verified once, one leg from landed. So the bounce itself auto-boosts the card to priority 0 — the same boosted stamp the human ▲ boost writes, read by the claim ranking, the train rank and the Backlog; it decays only when the card re-enters the train (re-queue), never auto-expiring mid-repair. The review ask is RETIRED (BR#4962, per accepted BR#4960 — ask-as-visibility is gone): a coverage bounce (an authored push moved the head past the accepted verdict — a lapsed verdict, not a defect) parks the card blocked in place under the typed reason Awaiting review (verdict BR#N, anchored at head <9hex>) with the typed pr_merged await_event preserved and merge_train.awaiting stamped, so the trains panel renders it as an ◉ awaiting <age> chip — amber past the train.awaiting_ttl_h window (legacy train.review_ask_ttl_h), red past TTL + 2h grace, a lapsed park still listed (visibility without interruption). Zero asks and zero lane digests: the parked card in the backlog IS the human leg, and the human force-reviews it from the card — approve re-anchors the accepted verdict at the parked head (a carry leg per unlanded PR, via "force-review"), clears the park, decays the boost and re-queues the train's own flow (the merge + CI settle still land it); request_changes bounces impl exactly like the train's own bounce (repair leg, the reviewer note rides the brief, fresh verify on re-entry — the parked verdict is NOT carried across a requested change). Gate/conflict bounces keep the impl repair leg with no ask legs either — the repair leg re-enters review through the normal in_review handoff. Force-review is web/human-only (the route refuses gateway bearers with 403); the stop-the-line and signing-environment asks remain the only genuine-decision asks on a lane.
  • mergeStateStatus=behind pre-clears at the TRAIN gate only (BR#3727): behind is exactly "base advanced, branch still mergeable" — the state the carry law was written for, which the shared decideMergeAction (clean/unstable, untouched) bounces because the sweep has no update-branch authority. The train gate pre-clears it so the executor reaches its own gh pr update-branch: behind + a verdict-covered head + a clean trial merge → machine-clean update, verdict CARRIES, and the train merges when the new head's checks settle. Conflicts still bounce with the file list. The tick right after an update-branch reads blocked while strict branch protection re-runs the required checks on the new head — pending, not a defect: the gate WAITS on it (red head checks note; blocked with green checks — protection wants reviews — still bounces honestly).
  • UNKNOWN mergeability polls, never bounces (BR#4685): GitHub computes mergeability asynchronously — a read right after a base merge yields mergeStateStatus=null/UNKNOWN, and mergeable rides the same recomputation, so an answer is definitive only when the state names itself. The executor backoff-polls inside the tick (15s/30s/60s, MERGE_TRAIN_POLL_SECS overrides) — bounded by the lane lease, renewed around every wait — until GitHub answers, and the shared gate WAITS on an unanswered state: never a bounce, and never a merge on an unconfirmed state (the old null-state fall-through is closed). Bounce only on definitive DIRTY/CONFLICTING. Receipt: the 2026-10-28 ledger, where PRs #279/#281 bounced 28× with merge_state_status:null — every "CONFLICTING" row of that loop trial-merged CLEAN (a stale read, not a conflict).
  • Mechanical defects bounce with the exact defect named — conflicts, red base, red head, unsigned commits, a head the verdict no longer covers — the same law the verify round enforces (rule 5c), now executed by the train. Human-leg defects (signing-environment, external mirrors) file ONE class ask per episode, never one per PR.
  • Every bounce names its KIND (BR#4066) — and the kind picks the brief's copy and the next owner it cites. Coverage: the head moved past the accepted verify verdict (an authored push; no conflicts exist) → the brief says so and hands the card to the verify pool to re-verify at the new head — no conflict language, no file list. Conflict: trial-merge conflicts → the enumerated file list rides (a conflict bounce without it is refused outright — the "(no file list supplied)" dead-end cannot render), and the impl repair leg owns the resolve. Gate: a mechanical defect (mergeStateStatus, red head checks) → the defect is quoted verbatim and impl fixes the named defect. The executor trial-merges only a conflict-class bounce — a coverage or gate bounce never runs a trial rebase, so a stale mergeable=false read (BR#3712 class) lands as a gate bounce naming the stale state, never as a file-less conflict.
  • Multi-PR cards are legs, gated per leg (BR#3659): a card queued with several PRs is a list of legs (meta.prs; meta.pr stays the first leg for legacy readers). Coverage is judged per leg (verdictCoversHeadLeg — one leg's stamp/carry never covers another), the gate bounce names ITS leg's stamp, and the card appears in exactly the lanes that own one of its UNLANDED legs. A partial landing keeps the card queued (merge-train: queued — waiting on N leg(s): repo#n, …); the final close cites every leg's merge commit.

The ops (MCP + HTTP)

train.status (GET /train mirrors it) · train.lease (acquire / renew / release) · train.event (merged | bounced | stopped | landed) · train.ci (executor-fed check runs on the merge commit) · train.lane (ensure / configure) · train.history (the train_events ledger — provenance serving). The executor never self-certifies: train:landed re-classifies the fed check runs and REJECTS a non-green report outright ("post train.ci instead and let the hub decide").

Setup

// admin.settings_set — lane config seeds the lane card at birth; the
// operator/executor refreshes it in place via train.lane afterwards
"train_lane": {
  "state-vector/bridge@main": {
    "required_workflows": ["Test", "CLI build (packages/cli)"],
    "ci_typical_minutes": 20
  }
},
"train_alarm_sink_id": "BR#3124"   // stop-the-line asks land here (default BR#3124)

required_workflows names the checks that must be green to land (the same checks the main-green gate ruleset enforces); ci_typical_minutes paces the fail-closed CI bound (unknown past 3× typical classifies red — the train never waits forever). Lane identity is repo@base, so one repo can run several lanes (e.g. main and a release branch).

The knowledge channel: typed faults, never HTML (BR#4212)

Every arm of the knowledge channel — the DO fetch entry, the worker's /api/kb/*, /api/kb-machine/*, /api/kb/admin/* proxies, and /mcp — is a catch-all: a storage-layer fault answers typed JSON with result_class: "infra_storage_fault" (HTTP 503), never an uncaught throw, a CF HTML error page, or a mislabeled 401 unauthorized. Before BR#4212 (incident BR#4117) one DO storage fault took the whole entry down uncaught: the runtime served HTML 500s where JSON acks belong, writes were silently lost, and /mcp's catch relabeled infrastructure failures as "unknown or revoked token" — a storage storm read as an auth storm. The classification law since: infra faults retry (503), auth rejections 401, and the two never masquerade as each other. Only the three genuine identify rejections (missing bearer, unknown auth token, auth token revoked) produce a 401.

The third class — the hub refusing — is not infra at all (BR#4390). A machine gate rejection (an illegal lifecycle edge, done-open-children, blocked-needs-typed-await, a claim race, …) is a deterministic answer: the DO stamps it result_class: "gate_rejected" and answers HTTP 400 carrying the rule name ([rule: …]), and the MCP layer surfaces it as a tool error with that message intact. Before BR#4390 an untyped 400 rejection fell into the infra fallback and came back as 503 "kb temporarily unavailable — retry" — so refusals that could never succeed on retry (the BR#4068/BR#3822 close attempts) read as an outage and burned dozens of futile retries. The classification law, complete: 5xx / typed infra_storage_fault / unclassified throws → 503 infra (retry the channel); 401 → the three genuine auth rejections; 400 → the hub refusing — read the rule, act on it, never retry blind.

The watchdog's view is machine-checkable via GET /stats (or POST stats): the response carries a channel block — per-route ok/err counters with write_ok_rate over the write ops, entry_faults (which storage stage faulted, how often, how recently), ws_send_fail (failed fanout sends, the in-DO proxy for clientDisconnected), and last_flush (the alarm tick's persisted snapshot — counters reset at isolate boot, window_started_ts says when). "Channel down" is thus a readable predicate (error rate + fault recency), not inferred from silence — phantom stalls are distinguishable from real ones. The DO alarm failure policy: degrade + retry — a faulted re-arm never throws out of alarm() (a throwing alarm loses its schedule entirely); the next fetch entry re-arms via the null-or-stale guard, and the sweep rides the 60s alarm retry chain.

The hub self-canary: daily synthetic lifecycle (BR#4177)

The hub is coordination infrastructure — it gets the same treatment the product gets: its load-bearing paths get a synthetic end-to-end canary, run daily, red alarm to the alarm sink. bridge canary is a single cron-friendly lifecycle pass against the deployed hub (same MCP surface as every agent, same BRIDGE_URL/BRIDGE_TOKEN):

export BRIDGE_URL=https://bridge.<your-subdomain>.workers.dev
export BRIDGE_TOKEN=<token from the Connect tab>
~/.local/bin/bridge canary          # one full pass (typ. 15–25 min; worst case ~65)
# cron/launchd daily — exit 0 = green, 1 = red (filed alarm), 2 = config fatal

Four legs, each named for a failure class actually observed:

  1. Duty spawn → dispatch → delivery — a qualified synthetic task must be claimed by a runner within 10 minutes and deliver its marker to the board within 10 more. Catches the "result delivered late/lost" class (two observed in a single week).
  2. Flip-on-event — a synthetic card parks on a typed {event:"time"} await; the unblock proxy sweep must flip it within two sweep cycles, no human action. Catches the stale-ledger class (BR#3620 family: resolutions hidden for a day). The pr_merged/run_success flavors of the same mechanism fire daily on the real train; the canary uses the timer flavor because the hub holds no GitHub credentials by design (BR#3516) — non-lane GitHub facts are never fed to it.
  3. Queue visibility — a fixture carrying the BR#4079 canonical stamp (merge_train.pr>0 + merge-train: reason, blocked) must appear in the derived train queue, then vanish the moment it retires. Catches the clobbered-stamp class (BR#3912). The fixture cites an obviously-fake PR (999901); if a train tick ever lands on it, the executor's gh pr view fails harmlessly before any gate — one lost tick, zero board writes.
  4. Reconcile accounting — BR#4170's report must run green (every PR accounted). Armed but skipping until that lane lands its report surface; a missing dependency is not a red.

Red path: one deduped ask per failing leg class, bound to the alarm sink (train_alarm_sink_id, default BR#3124) — the same child-ask pattern the train's stop-the-line uses, so a stuck class pages once, not daily. Green path: a [hub-canary] progress artefact stamps every run; 48 hours of silence on that feed is the canary's own liveness alarm (a canary that stops running must be observable too).

Fixtures self-clean by law: tagged [canary], retired to blocked with a 30-day typed timer (inert: never fires, never claimable, never queued). Task→superseded is not a client-legal edge (core.ts LIFECYCLES), so retirement — not deletion — keeps the board free of fixture accumulation without loosening the state machine. At most two inert blocked fixtures accumulate per run.

The board↔GitHub reconcile loop (BR#4170)

The merge train accounts for PRs it knows about. The reconcile loop closes the gap: every open reviewed PR must, at all times, be one of exactly three things on the board — bound to a card, ignored with a recorded reason, or in-flight on a spawned duty. Anything else past the actionable age is an ALARM to the sink, never a silence (the #2466 class: a session died post-open and an APPROVED+MERGEABLE PR sat invisible for 34 hours, outside every stamp-gated mechanism).

Division of labour is the credential split (BR#3499/BR#3507): the gateway daemon runs the read-only gh pr list leg and feeds raw observations via reconcile_report; the hub runs the join (reconcile.ts, pure), decides, and performs every board mutation. The daemon never writes the board; the hub never touches GitHub. A report per repo per cycle:

  • bound — an open card rides the PR (meta.pr / a meta.prs leg); queued cards are the train's business and are never re-spawned at.
  • ignored-with-reason — reconcile_ignore {repo, pr, reason} rules a PR off the board on the record; an ignore without a reason is refused (an ignore IS a reason).
  • in-flight / spawn — an orphan APPROVED+MERGEABLE PR past verify_age_h gets a binding card (meta.pr projection, session-less) plus the standard 5c verify duty as its child — the same template the normal in_review spawn uses, so ACCEPT queues the card exactly as BR#4079 prescribes. Reconcile creates tracking cards and standard duties only; it never queues, stamps, or merges.
  • destination-blocked (BLOCKED/BEHIND) binds WITHOUT a verify duty — a verify would bounce on mergeability by construction; the duty comes when a later cycle sees CLEAN.
  • dead duty ⇒ ALARM: the duty ledger (reconcile_duties) holds one row per repo+pr+kind EVER, so a duty that ended without binding its PR alarms instead of silently respawning over the corpse.

Reverse reconcile guards the other direction: a bound card whose PR vanishes from the open list starts a missing clock; two consecutive misses with a provenance row close the zombie binding via the machine proxy (BR#3287 — the train stamped the merge); with NO provenance it spawns a ruling duty instead — someone rules, never silence. Standing merge-lane cards are exempt from ALL of it (BR#5151, the BR#5050 incident): a lane card bound to a PR — even with a provenance row stamped on it and past the two-miss guard — is accounted lane-exempt and never closed, never dutied, never clocked; the train's settle path is the only lane writer. The close-protection gate matches: owner identity is no longer a close path for a standing card (the reconcile daemon's actor WAS the lane's owner) — humans (web:/human:) and the machinery (system:) only. And train:merged is stateful about merges: a PR merges exactly once, so an identical re-report collapses to an ack (crash-recovery is free) while a different merge sha for a recorded merge is a named protocol reject — never a silent provenance overwrite.

Knobs: reconcile.enabled / RECONCILE_ENABLED (default on), reconcile.interval_secs / RECONCILE_INTERVAL_SECS (default 1800, clamped 300..21600), reconcile.repos (CSV, default both bridge repos), reconcile.verify_age_h (2), reconcile.disposition_age_h (24), reconcile_alarm_sink_id (default BR#3124). reconcile_status is the operator view: settings, the accounting lines (the canary — N open reviewed / N bound / N ignored-with-reason / N in-flight — N accounted, ALARM tail on failure), the duty ledger, ignores, and the missing clock.

PR provenance: who touched main, from bridge

Every merge is attributable (BR#3287) — and under BR#3516 the stamping is ledger-native: there is no provenance sweep and no backfill job; the train's train:merged events stamp the chain as each merge lands. Per merge the ledger records: merge sha, head sha, the linked task (head branch bridge/BR-<id> is the authoritative join key, PR text the fallback), the accepted verify verdict covering that head (stamp or carry chain), the owning agent session, and the signer class (detached-cody / signed / partially-signed / unsigned) — plus the carry record when the verdict rode a machine-clean base update (BR#3516 §5).

  • Storage of record is the board (decision BR#3291): the task card carries meta.provenance (rendered as a provenance strip in TaskDetail, and on the board's Top rows as a PR link + verifier badge for in-flight work — BR#3307), the DO keeps a provenance index, and GET /api/kb/provenance?sha=<7-40 hex> resolves sha → PR → task → verdict → session (POST /provenance and the MCP surface mirror it for machine consumers). Unknown sha = honest 404 "no bridge provenance"; merges with no bridge task are recorded as absence — itself a signal. The verdict leg is honest too (BR#3307): when no accepted decision covers the head sha, verdict_id is recorded null — never an arbitrary stale verdict.
  • Absence renders explicitly (BR#3287 spec item 3): a Top row with nothing stamped carries a muted "no bridge provenance" chip — untracked merge, non-bridge work, or not yet trained. Absence is a rendered state, never silence.
  • Consumers: the stop-the-line culprit bounce (the ask names the just-merged card — the guilty chain is on the ask, one lookup away), humans auditing who landed what, and any agent needing receipts. train.history serves the raw ledger.

The closure tier: flipped proxies, terminal legs, receipts (BR#3361)

Work leaks at its END, not its middle: a card parked on a machine-checkable exit condition nobody re-checks (BR#3129 — the proxy flipped and sat ~20h), a done card whose final "close GH #N" leg has no owner (GH #2261), a done card claiming a close that never happened (BR#3220). The closure tier closes those leaks with four hub mechanisms:

  • Unblock-proxy watch (a): unblockProxySweep (paced by unblock_proxy_sweep_minutes, default 15, 0 = off) re-evaluates every blocked card's unblock_condition on each pass. The parser (packages/worker/src/kb/closure.ts) extracts machine-checkable atoms — pr_merged (PR + url/number), br_pr_merged (a "BR#N's fix PR is MERGED/merges" condition — with a repo URL, or bare and resolved against the default repo), issue_closed, br_done, br_answered ("BR#N answered" — the ask/question card left todo), run_success, pr_checks_green — and cuts the DUTY clauses ("then a fresh session closes…", "Then: …") so instruction text never parses as a precondition. Conservatism law: machine-looking text that yields no atom stamps the verdict unparseable (never auto-flips); atoms with no resolvable repo stamp unresolvable. Since BR#3516 the a genuine false→true transition still spawns exactly once. hub holds no VCS credentials and never reads GitHub live: unknown GitHub state (issue_closed, merges with no ledger row) stamps unresolvable and spawns nothing (run_success/pr_checks_green parks are probe-backed instead — BR#4658, below) — but three fact classes the hub owns outright still verify and auto-spawn the unblock duty with the receipts embedded (a trigger, never an authority: the spawned duty re-verifies first-hand): board-state atoms br_done and br_answered (BR#3620 — the answered-ask class, so a card parked on a human ruling flips the moment the ruling lands); and merge atoms backed by the ledger (BR#3620): pr_merged/br_pr_merged evaluate from the train-stamped provenance table (BR#3287 — train.event merged), receipts citing the stamped sha + signer, no credentials needed. Worker-executor verification probes (BR#4658) give the CI atoms a machine closer: for run_success/pr_checks_green parks the sweep enqueues a verification probe (deduped per atom across waiting cards, attempt-capped with backoff pacing), a daemon executor (the desktop app / bridge runner, beside the merge train, read-only gh) polls proxy.probes and feeds each verdict through proxy.verify; the HUB decides — a satisfied verdict at the awaited head stamps a proxy_verified event carrying the GitHub-URL receipt of record and re-enters the standard satisfied-proxy path (BR#4282 close/spawn, flood laws intact); a success at the WRONG head is a conclusive NO and the park stands (BR#3409 fail-closed); transient gh failures back off, and an exhausted probe returns the park to a loud unresolvable — visible, never a silent loop. Legacy prose cards the sweep cannot fully read run the disposition ladder (BR#4416 — the machine resolves what the card's own state can prove, a human is asked only for genuine ambiguity, ONE class ask per day): recoverProseLegs recovers the legs the card's meta already carries — an existing migration ask (BR#3578, blocked.await_migration_ask), meta/merge_train PR stamps, a terminal root blocker — and an ask leg that is already answered flips the card (BR#3620's answered-ask exit, ladder-native: the ask was recorded, answered, and the card never moved — dead). A leg that holds fires the exit with no ask; a recoverable typed leg re-types the park in place; a park with no leg at all releases to todo for re-scope; a no-legged runner/human-owned card collects into the ONE per-day class ask.
  • Terminal close-leg ownership (b): a new gate rule done-close-legs reads the "## Done when" section + the done note; every terminal "close GH #N" intent (or receipt-shaped claim like "GH#N fully closed") must carry a closure receipt or be owned by a CLOSED close-duty child. A rejected done auto-derives that child (Close-leg duty: …, flood-guarded per legs-shape) so the very next attempt has an owner — no orphan terminal legs.
  • Done-gate closure receipts (c): the receipt contract is state=closed + closing-comment id (or the comment URL) + guard run id (or n/a). Intent language ("will close", "fully closed", "close-guard blocked") is not a receipt. The done-time stamp (meta.close_leg_audit) records the claim; the LIVE GitHub re-verification sweep (closureAuditSweep) was deleted under BR#3516 — the hub no longer reads GitHub — so a false claim (the BR#3220 class — the board reporting a closed issue that wasn't) surfaces through the close-duty child's first-hand re-verification instead of a hub sweep; the gate still rejects an unreceipted done and auto-derives the close-leg duty exactly as before.
  • Duplicate-executor fence (d): orient exposes the executor lease (executor_session) on open cards and my_tasks; a state-changing op by a named session other than the lease holder while the holder is alive (<10m) is audited system:fence / duplicate_executor — observability, never a block. Prompt law backs it: a lost claim race means you are not the executor; stand down (rule 5d + protocol card 7). Hub/auto-spawned duties start session-less — the first real session to claim them stamps the lease.

Drills: packages/worker/test/closure.test.ts (pure parsers pinned to verbatim real card texts — plus the train suites — test/merge-train.test.ts (the pure gate, carry law, including BR#3423's stale-ledger specimen, BR#3620), test/closure-sweep.test.ts (real DO: GitHub atoms stamp unresolvable + unparseable honesty; the BR#3620 flips — answered-ask and train-stamped-merge cards flip, park-round close receipts; rejected done → close-duty child → receipted done unblocks the parent; duplicate-executor fence audit + lease), plus the train suites — test/merge-train.test.ts (the pure gate, carry law, queue and classification) and test/train-hub.test.ts (real-DO train drills: lease races, landed / stopped / bounced flows, the stop-the-line ask). admin.unblock_proxy_sweep drills on demand.

Answers unblock structurally: no self-edges, live-holder resume, the force-close exit (BR#3819)

The specimen: a watchdog rollup stamped a card child_of itself at creation; the done gate then counted the card as its own open child forever — every →done rejected "still has 1 open child" with no agent-repairable exit (MCP has no unlink), and answered asks kept resuming work onto runners that had already finished. Four laws close the class:

  • Self-edges are unrepresentable (a): the link API rejects src == dst for EVERY relation kind — loud, naming the self-edge class — so no writer (agent, rollup, future feature) can stamp a card as its own child ever again. The non-API write path is covered too: a replacement_id self-supersede is refused. Type errors no longer mask the honest reason — the self-edge check runs before relation validation.
  • The impossible state is invisible (b): a one-time migration (self_edges_v1, settings-stamped, idempotent across boots) deleted the existing self-edges (per-edge audited), and every gate reader excludes src = dst defensively — a gate must never read state that cannot exist.
  • The agent-executable exit (c): a card the done-open-children gate refuses because a child is genuinely unclosable (the self-edge class, or an orphan that cannot be closed first) closes with a force-close-with-reason in the done note — force-close-children: BR#12, BR#13 — why these children cannot be closed first — EVERY open child named (a partial list is refused by name), every named child audited as closed-by-force (meta.force_close + version note). Loud by construction; never a silent path.
  • Answers resume live holders only (d): the ask contract above — an answered ask resumes a holder with a live signal inside the stale window; a dead holder is released to the pool with a loud note, never resumed onto a finished runner.

Drill: packages/worker/test/br3819-self-edge.test.ts (real DO: per-relation self-edge rejection, migration stamp, partial/complete force-close, live-resume vs dead-release).

5 · Use it

  • Open the hub URL → Board (live fleet), For Me (proposed decisions awaiting your accept/reject — each shows the agent consensus tally and the reasons), Knowledge (search), Discussions (question threads)
  • Reports tab (BR#5126): high-resolution delivery metrics — tasks closed per bucket (hour/day/week over 1d–365d windows, avg task/h) and PR throughput (merged + gate bounces per bucket, avg PR/day) from the train ledger, drawn as full-width charts with gridlines, axes and hover detail. Served by GET /api/kb/metrics/reports (machine-plane friendly); the dashboard's compact throughput cell (BR#2937) stays on Top.
  • Every card has a stable URL (BR#3990): /card/BR-3990 deep-links straight to that card's detail panel, and opening or closing a card in the Board keeps the URL in sync — any card view is a shareable link (browser-back steps back out). The dash form is canonical (BR#3990 → /card/BR-3990); the route also accepts BR%233990 and the bare number. The same URLs ride the MCP payloads: artefact rows returned by orient, search_knowledge, get_artefact (+ see_also), related_artefacts and asks carry url (ask rows also artefact_url for their bound card), built from the hub's canonical origin — the CANONICAL_ORIGIN var (default https://hub.fleetbridge.run), regardless of which ingress served the request, so the workers.dev fallback never leaks into emitted links (BR#4695). An agent citing a card can hand you a link that opens it.
  • Create work: bridge create_artefact via MCP, or ask an agent to — tasks land in todo and the runner picks them up
  • Boost: the Backlog tab ranks work by pickup order; ▲ boost takes a task and all its open children to priority 0, ahead of unblock duty. Boost cascades server-side and is audited; ▼ unboost removes it.
  • Progress gauges persist until updated: a log_progress post that omits gauge fields (completion_pct, confidence_pct, risk_pct, context_pct) preserves the previous gauges on the card — gauge-less posts (heartbeats, relay lines, auto-notes) never clobber them, and the freshest gauge-bearing call wins.
  • The watchdog keeps states moving: silent agents are released back to the pool, an in_review park past review_stale_minutes (default 360) with no verifier running bounces and releases too, and gate-rejection blocks are reaped after 24h. Unblock tasks close directly with their verdict — they are never reviewed. A silent task whose trail proves landed side effects (GitHub PR/commit receipts, or the runner's own completed_result_lost marker) is released as completed_result_lost, not abandoned: the run happened and the report was lost between worker and hub. The class never penalizes the agent's behaviour score — the delivery channel is flagged instead (BR#3352). Silence never destroys a machine wait (BR#4067): a card whose meta carries a live wait — a merge-train queue slot (queued / awaiting-ci / bounced on a live PR leg), a typed await_event, or a bound ask — is parked blocked, never released to todo, by every release path (stall sweep, review-stall sweep, lapsed subprocess, reaper reassign, and the machine-actor backstop in the transition gate). The park names the silence in its reason and preserves the wait meta intact — the typed proxy (e.g. pr_merged) still owns the exit, so the proxy watch flips the card when the wait resolves. A hold parks with result class proxy_wait_hold, which is exempt from abandonment scoring — holding a wait is neither a lost run nor a dump. A dispatched run also gets grace: the claiming session's freshest hub ping counts as a liveness signal, so a run that is plausibly still in flight is not swept while quiet. The bounce re-entry is the one release carve-out (BR#4494): a train bounce re-opens its card in_progress with the prior executor session — usually dead — and in_progress is not claimable, so a bounced card whose re-entry sits silent past the stale window is demoted to a claimable todo (bounce brief + boost intact, result class bounce_reentry_demote, exempt from abandonment scoring) instead of re-parked; the carve-out is scoped to bounced + in_progress — queued/awaiting-ci and parked-blocked cards keep the full hold. Rework-loop escalation is unchanged: a held card's attempt counter keeps counting, so a card that cycles still reaches the human-ask park at 5 attempts. Rework-loop escalation is unchanged: a held card's attempt counter keeps counting, so a card that cycles still trips the churn redirect at 5 attempts — and that park is now typed, not prose: a 24h time cooldown is the machine exit (the sweep reads the atom; on fire the duty re-checks and a proven release resets the counter; BR#4248).
  • Blocker rollup: when ≥3 blocked cards share one machine-detectable root cause, the watchdog sweep auto-creates ONE boosted ROOT BLOCKER task — investigate → fix → one approval — and every queued card points at it (meta.root_blocker + a child_of link, so the board shows the tree and the boost cascades to all of them). Today's machine-detectable roots: a github.com/.../pull/N URL in the card's unblock condition (the one unmerged PR everything queues behind) and red/broken/failing-main language (the never-merge-onto-red-main class). Idempotent: cards already pointing at an open root are left alone, re-runs create no duplicates. A root closes only by an agent or human with receipts that the root cause is gone — the sweep never auto-closes it, and a later cluster of the same root opens a NEW root rather than reusing a done one.
  • Blocked parks get a clock too (BR#2893): churn-guard parks (retry instructions stamped as blocks) and parks whose unblock condition references only closed asks drain back to the pool automatically; an honest park with no external gate ages out past blocked_stale_hours (a setting, default 48). True external gates — dated checkpoints, GitHub issue/PR lanes, red-main — are exempt, and so is any park on a live machine wait (BR#4067): a typed await_event, a merge-train queue slot, or a bound ask owns its own exit, and the drain never attempts one (attempting it only re-bred a refused release — and a breach card — every sweep tick). A card whose owner has had no live session for owner_stale_hours (default 4) loses the claim regardless of status. And a parent that has burned UNBLOCK_SPAWN_CAP (3) unblock rounds stops spawning them and escalates to a human once. On the board, Top is the actionable fleet only; every parked card files in Backlog under its EXIT CONDITION — one group per typed await class (awaiting you · PR verification · PR merge (train) · CI · deploy · another task · GitHub issue · a date · green main · other), with churn parks under Timeout (release early if you disagree) and a live unblock duty shown as a chip on the row.
  • Anything needing a human lands in For Me (genuine decisions only — a merge-train coverage bounce parks the card Awaiting for a backlog force-review instead of filing an ask, BR#4960)
  • For Me never jumps (BR#3457): acting on a proposed decision (accept, or reject — which routes to supersede-with-reason, the decision lifecycle's only exit for a verdict that doesn't hold) pins the card to the bottom of its section with an actioning… chip and dead buttons; the card leaves the list only when a refetch confirms the server flipped it, and an error returns it to its slot with the reason inline. Server-side, one actionable verdict per target, ever: a newer verify-verdict for a target supersedes still-proposed older rounds at create, and the human's accept/reject sweeps any leftover proposed sibling (legacy stacked rounds included) — accepting a stacked group removes exactly one verdict and the card can never regrow.
  • Breach cards: when the task machinery loops (rejection storms, double-spawn attempts, invariant violations, version-log replays that disagree with stored state), the server folds the audit stream into one open breach-tagged card per (task, class) on the Board — the loop's fingerprint and its sample event, ready to investigate. A closed card never re-arms its key: every card stamps a fold high-water (the newest event its adjudication covered — its raised group plus everything that coalesced while it was open), and a later fold trigger counts only events strictly above that mark, so already-adjudicated events can never resurrect as a duplicate card while they remain in the lookback (BR#3443); genuinely new events raise a fresh card. Not every rejection counts: claim races never do — both a raced double-claim (lifecycle-edge rejections) and a non-owner's attempt to release a claimed card (the ownership gate's release-needs-ownership rejections, e.g. a dispatched session's rule-9 release on a runner-claimed task) classify as claim_race and are priced as noise, so no pile-up of them ever raises a breach card (BR#3337, BR#3429). The classifier matches the guard's structural pair (claimed by + only the owner), not a sentence literal — a wording edit to the gate message can no longer silently re-classify its rejections (PR #210's proven-unblock rewording did exactly that until BR#3611). Three release-protocol truths ride with it: a gateway may release its own attributed claim (claims are attributed — owner = the dispatched agent — so the raw gateway actor never matches the owner; the self-release exemption is token-derived and unforgeable, while a different gateway's release stays gated), and the runner only posts its "stale claim released" note when the release actually happened — a refused release stays noteless (the board never records a release that didn't happen) — and the stale-claim flow re-checks the live card before touching it: a release fires only while the card is still in_progress and still attributed to this runner, so a card the watchdog already redirected (blocked) or another dispatcher already re-claimed is skipped outright (no rejection, no note, no re-claim), and the recovery re-claim carries a resolution note re-stamping the card's own blocked reason + unblock condition (BR#3823). The replay fold re-anchors across historical chain gaps (a row that doesn't chain is healed and, when it's the newest row, audited as a chain_break) — the breach fires when the fold's end state disagrees with the stored status, not forever after on ancient damage (BR#3322). Known gates get named classes: the BR#3578 fail-closed typed-await park gates classify as gate:blocked-needs-typed-await (a park with no grammar-valid await_event) and gate:malformed-await-event (an await_event that fails the closed grammar — e.g. a run_id sent as a number) instead of folding into the unclassified bucket (BR#3738), and the verdict-done receipts gate classifies as gate:verdict-receipts — a rejection carrying the audited [rule: <name>] tag classifies by its rule name FIRST (the gate's own message embeds "PR link", which the older message-stem ladder mis-folded as gate:review-needs-pr, leaving the receipts class unreachable for its own gate; specimens BR#3735, BR#4291), with the stems kept as the fallback for suffix-less legacy reasons (BR#4295). The BR#4067 release-hold backstop classifies its refusals too: a machine silence- or owner-death release correctly held by a typed wait or a train queue slot is benign by design — the proxy watch / the answer / the train owns the exit — so its rejections classify as release:held-typed-await / release:held-train-queue instead of piling into unclassified (specimens TASK-016/BR#4563, BR#3727/BR#4573). And the blocked-park drain's ask-ref scan is word-bounded, so an unblock condition naming a task id (TASK-016) no longer yields the phantom ref ASK-016 that made the drain re-attempt a refused release every sweep tick (BR#4563). The receipts gate's rejection and every unblock-duty spawn template now teach the accepted note shape — any URL, or one of the verdict tokens (PR / merged / passed / unblocked / still blocked) — so a duty holder closing with honest findings prose passes on the first try instead of burning rejections (each rejection is a breach event) reverse-engineering the matcher from source (BR#4295). And the bootstrap teaches every dispatched session the retry law that keeps the loops from forming at all (rule 4c + a mirrored PROTOCOL CARD clause, BR#4474): a 5xx / infra_storage_fault is retryable — same transition, backoff, heartbeat — while a SEMANTIC gate rejection (the error text names a rule: "gate: …", "requires …", "separation of duties", "open child", "malformed") is TERMINAL for that attempt — stop, read what it names, resolve the named precondition or take a lifecycle-legal edge; retrying a gate-rejected transition cannot succeed and every retry is an audited breach event (the 2026-10-28/29 storage-fault era uncorked four gate:open-children cards in 18 minutes from exactly these loops — BR#4403, BR#4473). And the BR#4067 release-hold backstop classifies as release_held, priced as noise like claim_race: a machine sweep's release attempt on a card parked on a typed wait or a merge-train queue slot being refused IS the system working (zero state destroyed — the proxy watch / the answer owns the exit), so no pile-up of refusals ever raises a breach card (BR#4562, specimen TASK-001). The park-drain sweep that kept re-attempting those refusals every tick now checks the same hold its sibling sweeps check before releasing — a held park is waited out, not re-refused — and the full alarm sweep gained an admin.alarm_sweep drill, since the drain was previously reachable by no test path at all.
  • Decisions collect consensus (BR#903): proposing a decision auto-spawns a Feedback: round (a question task) — agents contribute agree/disagree with reasons via log_progress; orient surfaces the open rounds (decisions_awaiting_consensus) and For Me shows the tally next to your accept/reject buttons. The round informs you; it never gates you.

Local development

npm run demo -w @bridge/webapp    # mock hub + web UI, offline
npm run build -w @bridge/webapp   # webapp dist is committed and served as worker assets
npm run test -w packages/worker  # worker tests (syntax → tsc --noEmit typecheck → bundle dry-run → vitest)
npm run test -w packages/contracts  # contract tests: fixtures ↔ zod schemas ↔ the recorded MCP registry ↔ config schema
npm run typecheck -w packages/worker  # worker type gate only — TS2304-class undefined names (the Oct 2026 board-500 bug class) fail here at any strictness

Shared UI kit

Every surface (web, gateway UI, mobile) renders from one component kit, packages/ui-kit (@bridge/ui-kit): design tokens (tokens.css) plus Btn / Chip / Card / Field / TextInput / TextArea / Select / ActionRow / FormRow / ConnDot / RelTime. Import the css once per shell (@bridge/ui-kit/ui-kit.css, tokens included) and use the components — surfaces keep layout css only and may re-point tokens (the webapp overrides the palette in its :root; mobile sets --kit-radius: 12px for touch scale). No surface re-declares button, chip, card or field styles: the drift those per-surface copies caused is the bug this kit removes.

Docs are part of the change. README.md and packages/site/content.md describe shipped behaviour — a change that alters user-visible behaviour (UI, config, protocol, gates) updates both in the same commit. Agents get the same rule via their protocol card (7b) and the dispatch delivery contract.

Packages

package what
packages/contracts @bridge/contracts — the shapes every runtime trusts, verified in CI: zod schemas + production fixtures for orient, the MCP tool table, pairing, provisioning, asks, and the config file
packages/specs the contract: JSON Schemas, wire docs, TS types, consistency gate
packages/worker Cloudflare Worker + KB Durable Object: /mcp, /ingest (bearer), /ws, /api/* incl. machine-plane mirrors (/api/kb-machine/*), serves the webapp
packages/webapp the full web UI (browser-only — no shell forks)
packages/ui-kit @bridge/ui-kit — shared design tokens + Btn/Chip/Card/Field/ConnDot/RelTime; one look for webapp, gateway UI and mobile
packages/desktop gateway desktop app: minimal gateway UI (ui/) + Rust dispatch core (src-tauri/)
packages/desktop/ui the gateway UI (auth, dispatch log stream, agent config, open-in-web)
packages/mobile/ui mobile UI (Fleet + For You + Voice console) — native iOS shell (packages/mobile/shell, Tauri 2)
packages/cli headless bridge runner binary (reuses the desktop dispatch core)
packages/site public landing page (static)
packages/adapters anything → /ingest shippers

License

Apache-2.0

About

bridge: decisions, activity flows, presence — agents + humans, one surface

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages