← Ledger


title: Phase 7c-c-b close — Polar SDK replaces entitlement env stub date: 2026-07-03 status: Accepted

Context

Phase 7c-c-a (2026-06-29) landed producer-side entitlement + approval seams with env-driven stubs. The entitlement stub read ROCKY_POLAR_TIER_<slug> + ROCKY_POLAR_SEATS_<slug> — sufficient to gate producers behind a shape, but not connected to real subscription state. Phase 7c-c-b's contract from that plan: swap the env stub for a real Polar.sh SDK integration, land the spec-pinned single home at console/src/lib/polar/, and wire a webhook so the customer cache stays fresh without synchronous Polar reads on the hot path.

Decision

Six-file src/lib/polar/ module (mode, products, customers, client, entitlements, webhook) plus the POST /api/relay/polar webhook route replaces the env stub. src/lib/agent/entitlement.ts shrinks to a re-export shim so producer imports (@/lib/agent/entitlement) keep resolving without a churn PR across 6 subsystems. Both existing call sites (agent/gate.ts, hearth/service.ts) become async on the entitlement path.

Key shapes:

  1. ROCKY_BILLING=disabled is the OSS-parity switch. Default is disabled. In disabled mode assertEntitlement returns {allowed: true} without touching disk or Polar; the webhook route still verifies its signature and processes events (a self-host can still drive the cache manually for testing), but a self-host that never talks to Polar never faults a gate. Redesign spec §Polar.sh integration: "the Polar adapter compiles to a no-op when disabled". This is not a build-time exclusion (that class of dead-code elimination is Node-hostile) but a runtime short-circuit before any Polar-flavored code path runs.

  2. The customer store is a cache, not the source. Polar is authoritative for subscription state. The polar-customers.json file records what we last heard on a webhook. assertEntitlement reads only the cache — a Polar API outage or slow-response must not fault a producer invocation. The webhook is the only writer of {tier, seats, status} for a workspace.

  3. Workspace binding lives Polar-side, via metadata.workspace_slug. No rocky-side "link" UI in this phase — a webhook without metadata.workspace_slug warns + no-ops. Checkout wiring, admin binding UI, and metadata reconciliation are future slices. Deliberate choice: keeping the binding in the Polar object model means we don't need a separate CRUD surface + persistence layer for the mapping.

  4. subscription.canceled flips status but does NOT downgrade tier. Redesign spec §229 requires a 7-day tier_downgrade_active_data grace with read-only mode. That policy needs a boot-time sweep + cron and is filed as 7c-c-b-followup. Today the gate simply denies with reason subscription canceled — safer to over-deny for the followup window than to under-deny and lose data guarantees.

  5. Webhook POLAR_WEBHOOK_SECRET unset → 401 on every request. Fail-closed. verifyWebhookSignature returns false on empty secret regardless of what the caller sent. Symmetric to how AIRLOCK_JWKS_URL unset would fail the callback route in 7c-c-c — untrusted input is untrusted unless we can prove it isn't.

  6. /api/relay/polar is public in middleware. Polar posts server-to-server; there is no operator session. The route body's signature verify is the only gate. Same shape as /api/health (auth-free by design).

Consequences

Related