← Ledger


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

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:

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

Acceptance verified

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