title: Phase 7c-c-a-fanout close — gate replicated to relay/sniffer/council + shared helper date: 2026-06-29 status: Accepted phase: 7c-c-a-fanout (rocky-side gate fan-out) predecessor: docs/decisions/2026-06-29-phase-7c-c-a-close.md spec: docs/specs/2026-06-29-agent-registration-v1.md plan: docs/plans/2026-06-29-phase-7c-c-a.md
Phase 7c-c-a-fanout close
Shipped
rocky-hq/consolePR #22 (merge commit69cedb5): fan-out the RALPH gate pattern to the other console producers and extract the approval-then-entitlement composition into a shared helper.src/lib/agent/gate.ts—gateAgentInvocation(registration, workspace_slug)returns{allowed: true}or{allowed: false, status: 402|403, reason}. Approval check first (403 on deny) per 7c-c-a §Locked decision 4; entitlement check second (402 on deny).POST /api/relay/route.ts— gate beforeexecute()whenx-rocky-workspaceheader is present. Denied paths short-circuit before pebble/n8n/anthropic routing and before anyagent.*emit.POST /api/sniffer/analyze/route.ts— gate beforeanalyzeRepository(). Denied paths never touch GitHub. Slots in AFTER role/rate-limit/parse checks and BEFORE the agent.registered emit.POST /api/workspace/route.tsactionconvene_council— gate every persona independently. First denial fails the whole convene with<persona.designation>: <reason>in the error body. Fires BEFORE any personaagent.*emit and BEFORE the stream is opened.POST /api/ralph/runs/route.ts— refactored to call the helper (12 lines → 4). Same semantics as 7c-c-a.
- 7 new specs; console suite 501/501 (+7 vs 7c-c-a); typecheck clean.
Locked decisions
1. Shared helper, not per-producer duplication
Every producer needs the same order (approval → entitlement), the same status codes (403 → 402), and the same reason-string surface. Inlining would guarantee drift over time. Helper keeps the invariant one-source-of-truth. Cost is a tiny module; benefit is the 7c-c-b/c swap flips one file.
2. convene_council: partial council is worse than failed convene
Council debate output is shaped by the set of personas that respond. If persona A is gated out but persona B is not, the operator sees a debate missing a voice — misleading, not obvious. First denial fails the whole convene; the error names the failing persona so the operator can resolve either the approval or the entitlement gap. Alternatives rejected:
- Emit
agent.registeredfor allowed personas + skip the invoked ones for denied. Splits truth across two projections; hatch consumer would show phantom-registered agents that never ran. - Silently drop denied personas. Debate visually appears complete but silently degraded. Worst UX; hard to debug.
3. stratt/doctrines NOT gated (registration-only)
Spec §Field semantics is explicit: "Polar check happens at invocation time, not registration time (registration is free)." Stratt is the one console producer that only emits agent.registered — no agent.invoked ever fires from doctrine upload. Adding a gate here would misapply invocation-time semantics to registration-time. Explicitly documented in console/CLAUDE.md.
4. HEARTH driver gate belongs in hearth, not console
Hearth's Go binary emits agent.* directly to /api/relay/agent via HTTPEmitter — there is no console-side callsite. Gate must live in hearth/internal/agent/ (Go). Landing that in the same slice would cross the language boundary + repo boundary for no coherence benefit. Filed as follow-up 7c-c-a-hearth; MILESTONES tracks it. The console-side seams (helpers + wiring pattern) are portable to the Go side when it lands.
5. RALPH refactored to the helper, not left inline
Consistency wins over the tiny risk of refactor churn. If the composed contract changes (e.g. 7c-c-c adds a third check), only one file changes and all 4 producers pick it up.
6. Sniffer gate slots AFTER role/rate-limit/parse
Rate limit runs first because rejection is per-user, not per-registration — hitting the rate limit at the operator level should short-circuit before we build a registration. The gate then runs on the built registration before the analyzer's network I/O. Order in the route: role (403) → rate-limit (429) → parse (400) → gate (402/403) → agent.registered emit → analyze.
7. Council gate not covered by route-level test in this slice
The convene_council code path is streaming and heavy to mock. Gate helper itself is tested (src/lib/agent/gate.test.ts — 6 specs) and the wiring is a straight-line for-loop over personas returning on first denial. Documented in the commit body as a coverage tradeoff; can be lifted in a follow-up if convene_council grows other route-shaped test cases.
Deferred
7c-c-a-hearth— port the gate pattern tohearth/internal/agent/Go handlers. Awaits either the airlock signed-token contract (so hearth can validate it inline) or a decision to land Go-side env-driven stubs now.- 7c-c-b (external) — real Polar SDK replaces entitlement env stub. Zero-change ripple to producers because the helper is the seam.
- 7c-c-c (external, airlock) — real
agent.approveverb + signed cross-service token replaces approval env stub. Also upgrades hatch 7c-b'sx-rocky-user-role: observerheader to the signed token. agent.revokedproducer emission — teardown verb lives with 7c-c-c.
Acceptance verified
npm run typecheckclean.npm run test:run501/501 (+7 vs 7c-c-a): 6 gate helper specs + 1 relay 402 deny + 1 sniffer 402 deny (RALPH deny specs preserved via the refactor).- Manual smoke: with
ROCKY_POLAR_TIER_WS=solo+ a producer registration declaringtier_floor: fleet, all 4 gated routes short-circuit to 402 before any downstream call.
Phase 7 status after this slice
| Slice | State | Notes |
|---|---|---|
| 7a | closed | contract published |
| 7b | closed | 6 producers + RALPH terminal observer |
| 7c-a | closed | rocky audit + GET projection |
| 7c-b | closed | external hatch consumer |
| 7c-c-a | closed | seams + RALPH gated |
| 7c-c-a-fanout | closed (this) | relay/sniffer/council gated; shared helper extracted |
| 7c-c-a-hearth | not-started | Go-side gate in hearth RPC handlers |
| 7c-c-b | not-started (external) | Polar SDK swap |
| 7c-c-c | not-started (external, airlock) | agent.approve + signed token |
Console producer coverage now complete. Remaining rocky-hq work is the hearth Go-side gate; everything else is external.
Sources
docs/specs/2026-06-29-agent-registration-v1.md§Field semantics.docs/decisions/2026-06-29-phase-7c-c-a-close.md— immediate predecessor (seams + RALPH gate).docs/plans/2026-06-29-phase-7c-c-a.md— plan (7c-c-a-fanout is the explicit follow-up scoped in §Non-goals).