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.
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_atto matchingpool_provision_laddersrows. - 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.go—Transition()implementationinternal/entitlements/grants.go—CreateGrantAndMaterialize,RevokeGrantAndRematerializeinternal/server/operator_enrollment.go—IssueGrant,RevokeGrantAndTransition,ExtendGrantinternal/server/operator_partials.go—CreateGrant,RevokeGrant