Roadmap: Stage 5 hardening as-built; Stage 3/4 status corrected

Stage 5 (CB-501..505) is complete and documented as-built, including the
finding that drove it: bridged had exactly one security control, the loopback
bind, and any caller that was not a recognised worker pane was treated as the
PRIMARY. CB-501 inverts that default (ANONYMOUS is the fallback) and refuses to
start on a non-loopback bind under loopback-trust.

Also records that CB-505 must enforce on BOTH entry paths: BridgeMcp calls the
service layer directly and /mcp is a raw servlet that never traverses Javalin's
before filter, so the wiki's long-standing "MCP is a thin adapter over the REST
core" is not literally true at code level.

Corrections to stale rows:
- Stage 4: CB-402 landed (ded226a), was still listed as future. Flagged as
  code-complete but NOT live-dogfooded — the one known-unverified item.
- Stage 3: CB-301-ext / CB-302 / CB-304 marked shipped; CB-302's "STATE.md"
  framing superseded by the worker-opened-PR checkpoint that actually shipped.
- Test count 147 -> 307, and clarified that figure excludes contract tests.
2026-07-29 22:28:57 +07:00
parent b646be108d
commit ef3e68a400
+57 -11
@@ -31,8 +31,8 @@ gantt
| **1 — Walking skeleton** | One review, happy path | Opus mounts `bridged` (MCP), calls `bridge_send` with a diff, gets a review back from a real `ccs gx00-vllm claude` worker. Single hardcoded profile, same host, no guard/lifecycle. |
| **2 — Contract + guard** | Trust the reply, trust the boundary | Structured [envelope](7-Use-Cases#mechanism-4--the-id-contract-envelope) + worker `bridge_reply`; reply rendezvous; subscription guard via `ccs env`; reviewer skill. |
| **3 — Lifecycle + discovery** | Reuse, recycle, choose | Session manager (spawn/reuse/recycle Ralph loop, `idle_ttl`); `bridge_list` roster+live; multiple profiles. (`bridge_ask` landed early in Stage 2.) |
| **4 — Pluggable peers** | PeerLauncher SPI + first adapter | Extract `PeerLauncher` SPI in-tree; one adapter (`ClaudeCodeLauncher`); core depends on the interface; behaviour-preserving. Stage B (CB-402, 2nd adapter e.g. Codex) and Stage C (dynamic external plugin loading) are future work. |
| **5 — Harden** | Production shape | Auth/TLS, `/metrics` + `/healthz`, mock-socket CI, systemd unit, per-session authz + audit. |
| **4 — Pluggable peers** | PeerLauncher SPI + two adapters | ✅ Extract `PeerLauncher` SPI in-tree; `ClaudeCodeLauncher` (Stage A) **and** `OpenCodeLauncher` (Stage B, CB-402) routed by a `kind:` discriminator through `CompositePeerLauncher`. Stage C (dynamic external plugin loading) remains future work, gated by a trust/capability model. |
| **5 — Harden** | Production shape | ✅ Bearer auth + fail-fast exposure guard, `/metrics` + `/healthz`, mock-socket CI on the Gitea runner, launchd + systemd units, per-session authz + audit log. TLS deliberately terminates at a reverse proxy, not in the daemon. |
## Tech stack
@@ -115,9 +115,9 @@ Compact scope; expand into detailed tickets when a stage starts (as Stage 1 is b
| Stage | Tickets |
|---|---|
| **2** | `CB-201` envelope schema + codec · `CB-202` worker `bridge_reply` tool + reviewer skill · `CB-203` reply rendezvous (corr match; resolve on reply *or* the `working→idle` edge) · `CB-204` subscription guard via `ccs env` + allowlist · `CB-205` blocked-worker path (`bridge_ask`) |
| **3** | `CB-301` session manager (spawn/reuse/recycle) · `CB-302` Ralph checkpoint (`STATE.md` + commit) · `CB-303` `idle_ttl`/`context_cap`/drain · `CB-304` `bridge_list` roster+live · `CB-305` multi-profile routing (`role@profile`) |
| **4** | `CB-401` ✅ PeerLauncher SPI Stage A — extracted in-tree, one adapter (`ClaudeCodeLauncher`), core uses `PeerLauncher` interface, main @ `3aa69a9`, 183 tests green. `CB-402` 2nd coding-agent adapter (e.g. Codex) — Stage B, future. Stage C — dynamic external plugin loading, future, gated by trust/capability model. |
| **5** | `CB-501` bearer auth + TLS · `CB-502` `/metrics` + `/healthz` · `CB-503` mock-socket CI · `CB-504` systemd unit + ordered start · `CB-505` per-session authz + audit log |
| **3** | `CB-301` ✅ session manager (spawn/reuse/recycle) · `CB-301-ext` ✅ per-worker git worktree + config-parity overlay (`97ecc71`) · `CB-302` ✅ worker checkpoint — **shipped as commit→push→**_**worker-opened PR**_ (`64e70ef`: repo-scoped forge-token injection + the implementer skill), which **supersedes** this row's original `STATE.md`-file framing; see `docs/Worker-Git-Workflow.md` · `CB-303` ✅ `idle_ttl`/`context_cap`/drain · `CB-304` ✅ `bridge_list` roster+live (`9fe04bf`) · `CB-305` ✅ multi-profile routing |
| **4** | `CB-401` ✅ PeerLauncher SPI Stage A — extracted in-tree, one adapter (`ClaudeCodeLauncher`), core uses the `PeerLauncher` interface, main @ `3aa69a9`. `CB-402` ✅ **Stage B landed** (`ded226a`) — `OpenCodeLauncher` as the SPI-proving second adapter: shares none of Claude's private seams (no `ANTHROPIC_BASE_URL`, no `SubscriptionGuard`), mounts the bridge MCP via a generated `OPENCODE_CONFIG`, and is routed by `kind:` through `CompositePeerLauncher`. ⚠️ **Code-complete but not yet live-dogfooded** — see the note below. Stage C — dynamic external plugin loading, future, gated by trust/capability model. |
| **5** | ✅ **All landed.** `CB-501` bearer auth + non-loopback-bind fail-fast (TLS at a proxy, not in-JVM — see `docs/CB-5xx-Hardening.md` D3) · `CB-502` `/metrics` (zero-dependency Prometheus renderer, D4) + `/healthz` · `CB-503` mock-socket CI (`.gitea/workflows/ci.yml`) · `CB-504` launchd agent + systemd unit + herdr-socket startup wait · `CB-505` per-session authz table + audit log |
## CB-401 — Peer Launcher SPI (Stage 4)
@@ -128,10 +128,56 @@ Stage A has landed on main:
- ✅ **Core decoupled** — `session.SessionManager` now depends on the `PeerLauncher` interface and keys its registry on `PeerHandle.id()` (equal to herdr `paneId` for the Claude adapter, so no value change).
- ✅ **Behaviour-preserving** — full green gate on main @ `3aa69a9`, **183 tests**.
**Future sub-stages (not started):**
**Stage B / CB-402 — landed on main (`ded226a`), with one caveat:**
- **Stage B / CB-402:** a second in-tree coding-agent adapter (e.g. Codex) to prove the SPI holds.
- **Stage C:** dynamic external plugin loading (`ServiceLoader`/jar discovery). This is gated by a trust/capability model — a launcher runs at daemon privilege and can inject env/tokens into peers, so third-party plugins are not enabled without that model.
- ✅ `HerdrPeerLauncher` base extracted; `OpenCodeLauncher` implements the three divergent hooks.
- ✅ `kind:` discriminator on worker profiles; `CompositePeerLauncher` routes spawn/stop/reap/list by kind.
- ✅ The SPI is proven provider-neutral: opencode uses **none** of Claude Code's private launch seams.
- ⚠️ **Not live-dogfooded.** Every prior ticket (CB-306/307/108) was gated on a live run; this one
merged with the dogfood deferred ("needs a running-daemon restart + a resolved Gemini provider").
`opencode` is not installed on the dev host and the provider question
(CB-402 §7 Q1) is still open. **This is the one known-unverified item carried into the
cross-host stage** — gitea issue #7 stays open until the §5 checklist runs.
**Stage C (future):** dynamic external plugin loading (`ServiceLoader`/jar discovery). Gated by a trust/capability model — a launcher runs at daemon privilege and can inject env/tokens into peers, so third-party plugins are not enabled without that model.
## CB-5xx — Stage 5 hardening (as-built)
The single-host close-out, done **before** cross-host rather than after, because CB-308's own
gating concern is the trust model and it inherits whatever identity shape lands here. Full design
and decision record: **`docs/CB-5xx-Hardening.md`**.
The finding that shaped the stage: `bridged` had **exactly one security control — the loopback
bind**. `ConnectionIdentity` resolves a worker from its connection (unforgeable), but *any* caller
that was not a recognised worker pane — including, had the bind ever widened, an arbitrary remote
client — was treated as **the primary**, the most privileged role on the bus. CB-501 inverts that
default: `ANONYMOUS` is now the fallback and `PRIMARY` must be established.
- **CB-501 ✅ auth.** `auth.mode: loopback-trust` (default, the historical behaviour named honestly)
or `token` (bearer required of every non-worker caller). Worker identity is *never* token-gated,
so enabling auth cannot lock the fleet out of `bridge_reply`. Constant-time token comparison.
**The highest-value line in the stage is a startup check:** a non-loopback `bind.host` under
`loopback-trust` now *refuses to start* rather than silently promoting every reachable client to
primary. TLS terminates at a reverse proxy by design (D3), not in the JVM.
- **CB-502 ✅ `/metrics`.** Zero new dependencies — a ~150-line Prometheus text renderer instead of
Micrometer (D4), because this pom already hand-reconciles Jackson 2/3 and a Jetty BOM and carries
four accepted-CVE advisories, and the project's mandated dependency CVE gate could not be run.
Instrumented at `MessageService` — the single funnel both surfaces share.
- **CB-503 ✅ CI.** `.gitea/workflows/ci.yml` on the (already-running) Gitea Actions runner. Needs
no contract-test flag: the pom's `default-excludes` profile already excludes `@Tag("contract")`,
so a plain `mvn -B clean install` *is* the mock-socket surface.
- **CB-504 ✅ supervision.** launchd agent (the real target — this host is macOS, there is no
systemd) **and** a systemd unit for the Linux gateways CB-308 adds. Ordering directives are
advisory; the actual fix is that bridged now **waits up to 30s for the herdr socket and then
serves degraded** instead of crashing into a restart loop on a boot-order race.
- **CB-505 ✅ authz + audit.** The role table enforced on **both** entry paths — and that plural is
the point. The wiki has long described MCP as "a thin adapter over the REST core"; at code level
it is not. `BridgeMcp` calls the service layer *directly*, and `/mcp` is a raw servlet on Jetty's
context handler that never passes through Javalin's `before` filter. Enforcing only at REST would
have left `/mcp` wide open. The load-bearing rule is *own-session-only*: a worker may reply or ask
only as itself (already structurally true over MCP, newly true over REST, which had simply trusted
the session id in the URL path). Audit lines are JSON to a dedicated appender and **never carry
message content**.
## Delivery reliability & multi-host (CB-306 – CB-308)
@@ -213,9 +259,9 @@ comes in Stage 2). This is the thinnest end-to-end vertical slice.
**Definition of done for the stage:** `CB-107` demo passes.
> **Build status — Stage 1 COMPLETE** (in `bridged/`, Maven · Java 25 · **147 tests green — 141
> unit/acceptance + 6 live-herdr contract**; the suite has grown with the Stage 2 work noted
> below). The `CB-107` end-to-end demo gate passes and the
> **Build status — Stage 1 COMPLETE** (in `bridged/`, Maven · Java 25 · **307 unit/acceptance tests
> green** as of the CB-5xx close-out; the live-herdr and broker contract tests run separately via
> `mvn test -Pcontract`). The `CB-107` end-to-end demo gate passes and the
> bridge is **dogfooded**: an Opus primary delegates real tasks to off-subscription workers that
> reply through it (delegated code reviews have produced committed bug fixes).
> ✅ **CB-101** herdr client — connection-per-call, contract-tested vs live 0.7.0.