title: Phase 7c-c-a close — entitlement + approval seams; RALPH gate wired date: 2026-06-29 status: Accepted phase: 7c-c-a (rocky-side producer gating for external Polar + Airlock) predecessor: docs/decisions/2026-06-29-phase-7c-b-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 close
Shipped
rocky-hq/consolePR #21 (merge commitf2dfa6a): two producer-side helpers with env-driven stubs, plus the canonical RALPH submit producer gated on both.src/lib/agent/entitlement.ts—checkPolarEntitlement({workspace_slug, tier_floor, seats_required}). Env-driven stub readingROCKY_POLAR_TIER_<SLUG>(defaultsolo; unknown strings floor tosolo) andROCKY_POLAR_SEATS_<SLUG>(default1; non-numeric or negative fall to default). Tier laddersolo < team < fleet < enterprise. Deny reason strings match the spec verbiage:tier below floor (<current> < <floor>)/insufficient seats (<current> < <required>).src/lib/agent/approval.ts—checkAirlockApproval({agent_id, requires_approval, airlock_verb}). Short-circuits toapproved: truewhenrequires_approval: false(n/a per spec §Field semantics). Otherwise readsROCKY_AGENT_APPROVED_<AGENT_ID>(1ortrue→ approved; else deny withpending airlock <verb>where verb defaults toagent.approve).src/app/api/ralph/runs/route.ts— gate order: role → parse → build registration → approval check (403 on deny) → entitlement check (402 on deny) → HATCHralph.run.submitted→submitRun→agent.registered+agent.invoked→ terminal observer spawn. Denied paths short-circuit BEFORE any ralph serve call and BEFORE anyagent.*emit — a denied invocation is a non-event on the projection. Mirrors the sniffer 429 fail-early pattern..env.exampledocuments both env prefixes with the note that they are 7c-c-a stubs pending 7c-c-b (Polar SDK) + 7c-c-c (airlock verb).
- 24 new specs; console suite 494/494 (+24 vs 7c-a); typecheck clean.
Locked decisions
1. Two seams, two backing sources, env-driven stubs
Entitlement (Polar) and approval (Airlock) are distinct producer gates with different external sources. Modeling them as one combined "canInvoke" check would collapse two dimensions the spec keeps separate. The RATE column and the APPROVAL column are two operator concerns on the hatch projection — the producer gate mirrors that split.
Env stubs (not "TODO" no-ops): the stub is a real gate. Setting ROCKY_POLAR_TIER_WS=solo and declaring a tier_floor: fleet registration DENIES today. That's the shape of the real thing; only the source-of-truth changes when 7c-c-b/c land.
2. Unknown Polar tier strings floor to solo (conservative)
ROCKY_POLAR_TIER_WS=godmode → treated as solo, not "assume the highest". A misconfigured env var must never silently grant a higher entitlement. Explicitly tested. Same principle for negative / non-numeric seat counts (fall to the default 1, never wrap or grant unlimited).
3. Deny short-circuits before HATCH emit
Both denial paths return the error response BEFORE emitHatch(ralph.run.submitted), BEFORE submitRun, and BEFORE emitAgentHatch(agent.registered|invoked). A denied invocation is a non-event on both projections — HATCH ledger and the agent projection agree that nothing happened. This preserves the projection invariant that emitted events reflect actual work.
Alternative rejected: emit agent.registered on first hit anyway (regardless of deny) so hatch shows a pending row. Overreaches on producer semantics — hatch already renders unregistered agents as OWNER: unknown per spec §Distinction; there's no need to force a registration for a rejected first attempt.
4. Order: approval before entitlement
403 (auth-shaped) precedes 402 (billing-shaped). If both gates would deny, the operator sees the auth reason first — matches the way the airlock model already surfaces auth failures ahead of quota failures elsewhere in the codebase.
5. Gate at RALPH only in this slice
Fan-out to relay/council/sniffer/stratt/driver is deferred. Two reasons:
- Pattern is copy-paste from the 12-line RALPH block; landing it in a single follow-up ("7c-c-a-fanout" or folded with 7c-c-b/c) keeps the seam-establishing PR small and reviewable.
- Some producers (stratt uploads, council convene) may need per-persona / per-doctrine gating semantics that surface once real Polar entitlements exist. Better to land the seam, watch the shape, then fan out.
RALPH is the highest-cost producer (spawns ralph serve runs), so gating it first is the highest-leverage placement.
6. Env-key mapping: uppercase, hyphens → underscores
Predictable transform for both workspace slugs and agent IDs. ws-a → ROCKY_POLAR_TIER_WS_A. ws-a-council-sage → ROCKY_AGENT_APPROVED_WS_A_COUNCIL_SAGE. Tested explicitly.
Deferred
- 7c-c-a-fanout — replicate the gate pattern in the other 5 producers. May land as one PR or fold into 7c-c-b/c.
- 7c-c-b (external) — real Polar SDK replaces the entitlement env stub. Function signature is the seam; only the body swaps.
- 7c-c-c (external, airlock) — real
agent.approveverb + signed cross-service token replaces the approval env stub. Also upgrades the hatch consumer'sx-rocky-user-role: observerheader (7c-b caveat) to a signed token. - agent.revoked producer emission — teardown verb lives with 7c-c-c. The projection supports it in advance (row retained with
revoked_tsset). - Per-invocation rate window at pebble — spec locks Polar to a boolean tier + seats gate, not a rolling-window rate. Rate-window enforcement is a separate discussion.
Acceptance verified
npm run typecheckclean.npm run test:run494/494 (+24 vs 7c-a).- 24 new specs cover: entitlement tier ladder (12 rank pairs across defaults / env overrides / unknown strings), seats guard (default / override / non-numeric / negative), tier-before-seats ordering; approval
required:falseshort-circuit,env=1,env=true,env=0, missing env, default-verb reason, customairlock_verbreason, hyphen→underscore mapping; route 402 tier deny, 402 seats deny, 403 approval deny, denials-do-not-emit-and-do-not-spawn-observer.
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 (this) | rocky-side entitlement + approval seams; RALPH gated |
| 7c-c-a-fanout | not-started | pattern replicate to relay/council/sniffer/stratt/driver |
| 7c-c-b | not-started (external) | Polar SDK replaces entitlement env stub |
| 7c-c-c | not-started (external, airlock) | agent.approve verb + signed token |
Sources
docs/specs/2026-06-29-agent-registration-v1.md§Field semantics.docs/decisions/2026-06-29-agentic-layer-projection.md— projection foundation.docs/decisions/2026-06-29-phase-7c-b-close.md— immediate predecessor (external hatch consumer).docs/plans/2026-06-29-phase-7c-c-a.md— this slice's plan.