Files
member-console/docs/grant-plan-safety.md
Christian Galo 751bae7768 Use plan ladder for org defaults
Add default_plan_ladder_id with a forward data migration and update
the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate
sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and
tests; add GetTierByLadderRank and pool/provision query helpers. Add a
CSP-safe confirm-action modal and wire operator actions to it. Close
plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE
pool lock to prevent ladder races.
2026-04-27 01:57:17 -05:00

3.2 KiB

Grant / Plan Ladder Safety

Audience: Backend developers working on entitlements, grants, plan ladders, or operator UI.

Last updated: 2026-04-26

See also: plan-architecture.md — where plan code lives and the sole-writer rule.

Incident Memory

This document records past safety issues and how to recognize them. For the architecture and rules, see plan-architecture.md. For operator-facing workflows, see plan-management.md.

2026-04 — Two-Path Problem (plan-sole-writer-guards)

What happened: Member Console had two ways to create a grant that provisions a product to an organization. The general Grants tab (CreateGrantAndMaterialize) created a pool_provisions row but never touched pool_provision_ladders or wrote pool_provision_transitions audit rows. Operators could grant plan-tier products through the wrong tab, leaving the ladder system inconsistent.

Gaps closed by plan-sole-writer-guards (archived at openspec/changes/plan-sole-writer-guards/):

Gap Fix Date
#1 — CreateGrant bypassing Transition() CreateGrant now rejects plan-tier products with an error directing operators to the Enrollment tab. 2026-04-26
#2 — RevokeGrant bypassing Transition(End) RevokeGrant detects active ladder attachments and routes through Transition(End) instead of bare RevokeGrantAndRematerialize. 2026-04-26
#3 — IssueGrant ladder state race IssueGrant takes FOR UPDATE on the pool row before resolving ladder state, serializing concurrent grant issuance. 2026-04-26

How to recognize it again: If a pool has an active pool_provisions row but no matching pool_provision_ladders attachment for a plan-tier product, the grant was issued through the wrong path. The fix is always to route plan-tier mutations through entitlements.Transition().

Database Safeguards

1. GiST Exclusion Constraint (pool_provision_ladders)

ALTER TABLE pool_provision_ladders
ADD CONSTRAINT no_overlapping_ladder_attachments
  EXCLUDE USING gist (
    pool_id WITH =,
    plan_ladder_id WITH =,
    active_during WITH &&
  );

This prevents two overlapping active rows for the same (pool_id, plan_ladder_id). It does not prevent a provision from existing without a ladder row, because the constraint lives on pool_provision_ladders, not pool_provisions.

2. Sync Trigger (trigger_sync_pool_provision_ladders)

Fires on UPDATE of pool_provisions:

  • Propagates status, activated_at, ended_at to matching pool_provision_ladders rows.
  • Does nothing if no ladder row exists for that provision.

This means a provision created via CreateGrantAndMaterialize is invisible to the sync trigger until a ladder row is manually added.

See Also

  • internal/entitlements/transitions.goTransition() implementation
  • internal/entitlements/grants.goCreateGrantAndMaterialize, RevokeGrantAndRematerialize
  • internal/server/operator_enrollment.goIssueGrant, RevokeGrantAndTransition, ExtendGrant
  • internal/server/operator_partials.goCreateGrant, RevokeGrant