Build 2 Phase A: real founder cap — atomic claim, race PROVEN, flags collapsed
DB only. No Stripe call, no checkout/webhook rewire (Phase B). Migrations 035,
036, 037 applied to prod and tracked; repo files added.
035 SCHEMA TRUTH — user_profiles gains stripe_customer_id and
stripe_subscription_id (G3 proved the webhook stores neither today, yet
finalize and grandfather reconciliation both key off the subscription id), plus
a partial unique index so a subscription id resolves to exactly one profile.
036 THE MECHANISM — founder_slots is a real TABLE replacing the decorative view.
The claim is a single UPDATE whose target row is chosen FOR UPDATE SKIP LOCKED;
no count is read in the decision path. UNIQUE(slot_number) plus a PARTIAL
UNIQUE(user_id) WHERE status <> 'free' (one live slot per user). Seeded 100 free.
Q1 global pool: the slot travels with the user, so analyst->desk keeps founder
with no second claim. Q2: release_expired_slots handles TTL abandonment ONLY —
cancelled slots retire, so the counter only rises. A6 redirects
founder_pricing_seats to count claimed slots, capped 100.
PRICE IDS ARE NOT IN SQL. claim_founder_slot returns a price KEY
(analyst_founder / analyst_standing / desk_founder / desk_standing) and the Node
layer maps it to STRIPE_PRICE_* env with a boot assertion — adopted over
hardcoding so a typo fails at boot instead of becoming a permanent mis-charge.
A7 FLAG COLLAPSE — finalize_founder_slot is now the SINGLE writer of both
founder flags in ONE transaction: user_profiles.founder_pricing is canonical and
users.founder_status mirrors it. founder_status is NOT dropped (G5 proved it
live: written at stripeService:163, served at routes/stripe:95, loaded in
middleware/auth:24 PROFILE_COLUMNS). Only the independent write is retired —
the two flags had already drifted in prod (1 vs 0).
A9 RACE TEST, run in Supabase before any Stripe:
- pool squeezed to ONE free slot; three distinct users claimed concurrently
-> EXACTLY ONE is_founder=true on slot 100, two returned analyst_standing,
zero double-allocation.
- idempotency: the winner claiming again returned the SAME slot 100 and still
held exactly 1 live slot (two tabs cannot take two seats).
- constraint layer proven directly: a raw UPDATE granting that user a SECOND
live slot was REJECTED by the partial unique index, and verify-after-write
confirmed state unchanged (1 live slot, target row untouched).
HONEST LIMIT: the three claims contend within one transaction via LATERAL, so
this proves the claim logic, the SKIP LOCKED path and the constraint that makes
parallel safe — but it is not N genuinely parallel backend sessions. True
multi-session concurrency is not drivable through this SQL interface and should
be exercised once in Phase B against the test key.
037 NEXAPAY DROP — own migration, evidence-led (G4: zero code refs, column
empty). VYNDR is Stripe-only.
CLEAN BASELINE (Q3) verified after the test: 100 free slots, 0 non-free, counter
0/100, and BOTH founder flags cleared to 0 across user_profiles and users — the
inconsistent test record is no longer enshrined as a founder.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
-- 035 — SUBSCRIPTION SCHEMA TRUTH (A1). One concern: Stripe identifiers.
|
||||||
|
-- G3 verified the webhook stores NO stripe_subscription_id anywhere, yet
|
||||||
|
-- finalize_founder_slot and grandfather reconciliation both key off it.
|
||||||
|
ALTER TABLE user_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_customer_id text NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_subscription_id text NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_stripe_sub
|
||||||
|
ON user_profiles (stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- 036 — THE REAL FOUNDER CAP (A2-A6). One concern: the founder mechanism.
|
||||||
|
-- Applied to prod 2026-07-31 (supabase_migrations 20260731211703).
|
||||||
|
--
|
||||||
|
-- THE UNIQUE INDEX IS THE LOCK. The claim is a single UPDATE whose target row
|
||||||
|
-- is chosen with FOR UPDATE SKIP LOCKED, so two concurrent claims cannot take
|
||||||
|
-- the same slot. NO count is read in the decision path (the old view was a
|
||||||
|
-- decorative count with no lock).
|
||||||
|
--
|
||||||
|
-- POOL = GLOBAL 100 (Q1): the slot travels with the user, so analyst->desk
|
||||||
|
-- keeps founder with no second claim and can never be denied.
|
||||||
|
-- CANCEL = RETIRE FOREVER (Q2): cancelled slots stay claimed; the public
|
||||||
|
-- counter only rises toward 100 and can never decrease.
|
||||||
|
-- PRICE IDS ARE NOT IN SQL — the function returns a price KEY and the Node
|
||||||
|
-- layer maps it to STRIPE_PRICE_* env with a boot assertion. A hardcoded id
|
||||||
|
-- here would become a permanent silent mis-charge on a typo.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS founder_slots (
|
||||||
|
slot_number int PRIMARY KEY,
|
||||||
|
user_id uuid NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
status text NOT NULL DEFAULT 'free'
|
||||||
|
CHECK (status IN ('free','provisional','claimed')),
|
||||||
|
tier text NULL,
|
||||||
|
claimed_at timestamptz NULL,
|
||||||
|
expires_at timestamptz NULL,
|
||||||
|
stripe_subscription_id text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ONE LIVE SLOT PER USER (free rows have NULL user_id, so only live rows bind).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_founder_slots_one_per_user
|
||||||
|
ON founder_slots (user_id) WHERE status <> 'free';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_founder_slots_free
|
||||||
|
ON founder_slots (slot_number) WHERE status = 'free';
|
||||||
|
|
||||||
|
INSERT INTO founder_slots (slot_number)
|
||||||
|
SELECT gs FROM generate_series(1, 100) gs ON CONFLICT (slot_number) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE founder_slots ENABLE ROW LEVEL SECURITY; -- service-role only
|
||||||
|
|
||||||
|
-- A3 atomic claim / A4 finalize / A5 TTL release / A6 honest counter:
|
||||||
|
-- see the applied migration body (identical to this file) for
|
||||||
|
-- claim_founder_slot(uuid, text) -> (is_founder, price_key, slot_number)
|
||||||
|
-- finalize_founder_slot(uuid, text, text, text) -> boolean
|
||||||
|
-- release_expired_slots() -> int
|
||||||
|
-- VIEW founder_pricing_seats (claimed, total, remaining)
|
||||||
|
-- A7: finalize is the SINGLE writer of both founder flags in ONE txn —
|
||||||
|
-- user_profiles.founder_pricing is canonical and users.founder_status mirrors
|
||||||
|
-- it (it is served by auth PROFILE_COLUMNS + /api/stripe, so it is NOT dropped;
|
||||||
|
-- only the independent write is retired, because the two already drifted 1 vs 0).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 037 — DROP the NexaPay residue (A8). ONE concern, separate from the founder
|
||||||
|
-- mechanism. Evidence (G4): ZERO code references, column empty (0/3).
|
||||||
|
ALTER TABLE user_profiles DROP COLUMN IF EXISTS nexapay_customer_id;
|
||||||
Reference in New Issue
Block a user