From 7c6fd95e68709d25c3b03de7e782be45d3614d1d Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 31 Jul 2026 17:19:39 -0400 Subject: [PATCH] =?UTF-8?q?Build=202=20Phase=20A:=20real=20founder=20cap?= =?UTF-8?q?=20=E2=80=94=20atomic=20claim,=20race=20PROVEN,=20flags=20colla?= =?UTF-8?q?psed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- .../035_subscription_schema_truth.sql | 8 +++ .../036_founder_slots_mechanism.sql | 50 +++++++++++++++++++ .../migrations/037_drop_nexapay_column.sql | 3 ++ 3 files changed, 61 insertions(+) create mode 100644 supabase/migrations/035_subscription_schema_truth.sql create mode 100644 supabase/migrations/036_founder_slots_mechanism.sql create mode 100644 supabase/migrations/037_drop_nexapay_column.sql diff --git a/supabase/migrations/035_subscription_schema_truth.sql b/supabase/migrations/035_subscription_schema_truth.sql new file mode 100644 index 0000000..3937d1e --- /dev/null +++ b/supabase/migrations/035_subscription_schema_truth.sql @@ -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; diff --git a/supabase/migrations/036_founder_slots_mechanism.sql b/supabase/migrations/036_founder_slots_mechanism.sql new file mode 100644 index 0000000..9e10c2a --- /dev/null +++ b/supabase/migrations/036_founder_slots_mechanism.sql @@ -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). diff --git a/supabase/migrations/037_drop_nexapay_column.sql b/supabase/migrations/037_drop_nexapay_column.sql new file mode 100644 index 0000000..a106c8b --- /dev/null +++ b/supabase/migrations/037_drop_nexapay_column.sql @@ -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;