title: Phase 7c-c-a-hearth close — Go-side gate in hearth RPC handlers date: 2026-06-29 status: Accepted phase: 7c-c-a-hearth (Go port of the producer-side gate) predecessor: docs/decisions/2026-06-29-phase-7c-c-a-fanout-close.md spec: docs/specs/2026-06-29-agent-registration-v1.md plan: docs/plans/2026-06-29-phase-7c-c-a.md external_repo: rocky-hq/hearth external_pr: 6 external_merge: 3cad131ef0c777664ddeeb4c68a6122ede3582c2
Phase 7c-c-a-hearth close
Shipped
rocky-hq/hearthPR #6 (merge commit3cad131): ports the console-side gate seams to hearth's Go RPC server. Wire-parity with the console helpers: same env keys, same tier ladder, same conservative unknown-tier floor, same approval-then-entitlement composition, same deny-before-emit invariant.internal/agent/entitlement.go—CheckPolarEntitlement(EntitlementInput) EntitlementResult. ReadsROCKY_POLAR_TIER_<SLUG>(defaultsolo; unknown values floor tosolo) andROCKY_POLAR_SEATS_<SLUG>(default1; non-numeric / negative fall to default). Tier laddersolo < team < fleet < enterprisevia privatetierRankovercontractsagent.TierFloor.internal/agent/approval.go—CheckAirlockApproval(ApprovalInput) ApprovalResult. Short-circuits whenRequiresApproval: false. Otherwise readsROCKY_AGENT_APPROVED_<AGENT_ID>(1ortrue→ approved; else deny withpending airlock <verb>where verb defaults toagent.approve).internal/agent/gate.go—GateInvocation(reg, workspaceSlug) GateResult{Allowed, Status:402|403, Reason}. Approval first (403 on deny), entitlement second (402 on deny). Mirrors the consolegateAgentInvocation.internal/server/server.go(checkGatehelper): wired into all four RPC handlers (provision/status/upgrade/teardown). Runs AFTER JSON decode and BEFOREbeginInvocation+ driver call. Denials writeErrorResp{Code, Message, Retryable:false}withcode = "approval_denied"or"entitlement_denied". Denied paths never touch the driver and emit noagent.*events.- Parity rule: empty slug ⇒ skip gate (matches
beginInvocation's no-owner ⇒ no-emission rule). .gitignore:go.work+go.work.sumadded — those reference sibling paths (../contracts/go) that only exist on developer machines.
- 22 unit specs + 4 server wiring specs;
go build ./...clean;go vet ./...clean;go test ./... -race -count=1green.
Locked decisions
1. Wire-parity with the console side
Same env key format (ROCKY_POLAR_TIER_<SLUG> / ROCKY_POLAR_SEATS_<SLUG> / ROCKY_AGENT_APPROVED_<AGENT_ID>; uppercase + hyphens→underscores). Same conservative unknown-tier floor. Same deny reason strings verbatim. An operator troubleshooting a 402/403 in production must not have to remember which subsystem's language they're reading. Divergence here would be a maintenance leak.
2. Env keys are shared across producers, not scoped per-subsystem
ROCKY_POLAR_TIER_IRIS_HQ=team grants entitlement for every producer whose registration owner is iris-hq, across both console and hearth. This matches the operator mental model: a Polar tier is per-workspace, not per-agent. Alternatives rejected:
- Per-agent env keys (
ROCKY_POLAR_TIER_IRIS_HQ_DRIVER_LOCAL_DOCKER) — misplaces the tier concept; Polar bills workspaces. - Producer-scoped env keys (
ROCKY_POLAR_TIER_HEARTH_IRIS_HQ) — same category error.
3. Deny returns typed error codes, not just HTTP status
Hearth's existing ErrorResp shape carries {code, message, retryable}. Denial writes code: "approval_denied" (403) or "entitlement_denied" (402), matching the vocabulary hatch's projection will render in the "why" column. Consumers can key error handling on the code, not just the status.
4. Teardown verb hardening deferred
The predecessor hearth/CLAUDE.md flagged: Teardown should ship with approval.required: true + airlock_verb: agent.teardown. This slice does NOT land that change. Reason: the env-driven approval stub could power it today, but the operator UX is brittle (each teardown requires setting an env flag with a specific per-agent key). Better to wait for the real airlock signed-token contract in 7c-c-c so approval flows through a proper verb, not a manual env toggle. Explicitly noted as future work in the hearth CLAUDE.md.
5. Outbound x-rocky-user-role: operator header unchanged in this slice
The HTTPEmitter continues to send x-rocky-user-role: operator when POSTing agent.* events to /api/relay/agent. This is the same 7c-b caveat carried forward: an airlock-signed cross-service token replaces the header stub in 7c-c-c. Not a regression — the header stub existed pre-slice and the gate seam is independent of the outbound emit auth.
6. Server tests validate wiring, unit tests validate logic
The 4 internal/server/gate_test.go specs assert: default registration allows end-to-end, empty slug skips gate, malformed JSON short-circuits before gate, conservative-floor rule holds through the wiring. Deny-path assertions on tier / seats / approval live in internal/agent/gate_test.go where the registration can be freely mutated. Splitting this way avoids reaching into BuildRegistration from server tests just to force a deny — the helper is already tested at its own level.
7. go.work gitignored
go.work references ../contracts/go — a path that only exists on developer machines where the contracts submodule sits sibling to hearth. Committing it would break CI (unresolvable path). .gitignore catches it now so a future git add -A doesn't sneak it in.
Deferred
- Teardown
approval.required: true— waits for 7c-c-c airlock verb. - Outbound signed cross-service token replacing
HTTPEmitter'sx-rocky-user-role: operatorheader — 7c-c-c. - 7c-c-b (external) — real Polar SDK replaces the entitlement env stub. Both console + hearth
CheckPolarEntitlementbodies swap; signature unchanged. - 7c-c-c (external, airlock) — real
agent.approveverb + signed token replaces the approval env stub. Both console + hearthCheckAirlockApprovalbodies swap; signature unchanged.
Acceptance verified
go build ./...clean.go vet ./...clean.go test ./... -race -count=1green acrossinternal/agent,internal/server,internal/driver/{fake,localdocker},test/integration.- 22 unit specs + 4 server wiring specs.
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 | relay/sniffer/council gated; shared helper |
| 7c-c-a-hearth | closed (this) | 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 |
Rocky-hq producer-side of Phase 7 is now complete across TypeScript and Go. Every emitting producer (RALPH, RELAY, SNIFFER, COUNCIL, HEARTH driver × 4 verbs) is gated. STRATT stays ungated per spec (registration is free). Remaining Phase 7 work is entirely external.
Sources
docs/specs/2026-06-29-agent-registration-v1.md§Field semantics.docs/decisions/2026-06-29-phase-7c-c-a-close.md— original seam.docs/decisions/2026-06-29-phase-7c-c-a-fanout-close.md— console fanout.docs/plans/2026-06-29-phase-7c-c-a.md— plan (§Non-goals flagged the Go port).rocky-hq/hearthPR #6 (3cad131) — external code change.