diff --git a/specs/build2-review-zero.md b/specs/build2-review-zero.md new file mode 100644 index 0000000..83cb628 --- /dev/null +++ b/specs/build2-review-zero.md @@ -0,0 +1,81 @@ +# BUILD 2 — REVIEW ZERO (report; mechanism NOT built) +2026-07-31. Nothing built, no Stripe object created or changed, no price logic touched. + +## WHY THIS STOPPED + +**I have no Stripe credentials in this environment** (`.env` holds ODDS/SUPABASE/INTERNAL keys — +**no `STRIPE_SECRET_KEY`**). The order's standing floor requires *"founder/standing/grandfather/race +all verified server-side"*. **I cannot verify any of them**, cannot create the standing price +objects the rollover needs, and cannot run the concurrent-checkout race test. + +This is a payment path where the failure modes are **permanent and customer-facing**: a race bug +mis-prices a subscriber *forever* (slot 101 on a founder rate, or slot 99 on standing), and a +grandfather bug overcharges a founder *every month*. Shipping that unverified is the one place +"probably right" is not good enough. **So: findings, then stop.** + +## WHAT I DID ESTABLISH (VERIFIED) + +**0.1 — Stripe IS live and FOUNDER price objects DO exist.** Proven indirectly but soundly: +`GET /api/founders/count` returns **`{available:true, claimed:0, total:100}`**, and +`routes/founders.js` returns **`{available:false}`** whenever `countFounderSeats()` is null — which +it is when `!STRIPE_SECRET_KEY || founderPrices.length === 0`. **`available:true` therefore proves +both the secret key and at least one founder price ID are configured in prod**, and that `claimed:0` +is a REAL count, not a fallback. (That route is honest by construction — *"any failure → hide, never +fabricate"* — which is why it can be used as a probe at all.) + +**STANDING objects: CANNOT DETERMINE.** Env is not readable from here, and `getPriceId` **falls back +silently**: a missing standing price yields the `PRICE_UNCONFIGURED` sentinel, which does not fail +until Stripe rejects the session. **So a missing standing object would not surface until the first +post-cap checkout 400s in front of a paying customer.** Verifying this is a 30-second check in the +Stripe dashboard and is **prerequisite #1**. + +**0.3 — THE COUNTER TODAY IS NOT A GATE, AND CANNOT BECOME ONE WITHOUT NEW WORK.** +- It is a **read**, not a claim: `countFounderSeats` lists Stripe subscriptions and returns a number. +- It is **cached 300s** (`CACHE_KEY 'founders:count'`), so it is stale by construction. +- **Founder pricing is gated by a CODE + EXPIRY, not by the count** (`VALID_FOUNDER_CODES`, + `FOUNDER_EXPIRY 2026-12-31`) — so today **anyone holding `FOUNDER2026` gets the founder rate at + any seat number**, and the cap is decorative. +- **Two simultaneous checkouts at slot 99 would both read 99 and both get founder.** There is no + claim, no lock, no unique constraint anywhere in the path. + +**0.2 — the entitlement the gate reads** is `config/tiers.js` capability `reasoning_visible` +(free false / analyst+ true), consumed by `snapshotGating.entitledToItemizedGrades` via +`resolveTierFromRequest`, which reads `users.tier`. **So a successful subscription must set +`users.tier`** for Build-1's gate to open. Whether the Stripe webhook currently writes that on +`checkout.session.completed` is **CANNOT DETERMINE without the webhook secret** — and it is +**prerequisite #2**, because a paid sub that does not flip `users.tier` sells access that never opens. + +## WHAT I WOULD BUILD, ONCE UNBLOCKED (design is settled, so this is fast) + +1. **An atomic slot claim, DB-backed** — a `founder_slots` table with a **unique constraint on + `(tier, slot_number)`**, claimed inside the checkout-session request *before* the Stripe call. + The unique index — not a count read — is what makes the race impossible: two concurrent claims + for slot 100 mean one INSERT wins and the other is rejected to standing. **The cached count must + be removed from the decision path entirely** and kept only for display. +2. **Price selection from the claim**, not from a code: claim succeeded → founder object; claim + rejected/cap full → standing object. **Retire the code+expiry bypass**, or it silently defeats + the cap. +3. **Grandfathering** is already native (a sub created against a founder price stays on it) — the + build rule is simply: **never call Stripe's price-update/migration on a founder subscription.** +4. **Founder-follows-upgrade**: on tier change, attempt an atomic claim on the *target* tier's + founder slots; success → founder object, failure → standing. Tie to continuous subscription by + **releasing the slot on cancellation** (that is what makes "break it → standing" true rather than + aspirational). +5. **Honest display**: real uncached count at render; **if the count cannot be served, show the + offer with no number** — the route already does exactly this, so follow its precedent. +6. **Beta framing** with no proven-edge claim, pointing at `/record` (shipped) as the honest + building record. + +## PREREQUISITES (all need Kev — none are code) +1. **Confirm/create the two STANDING price objects** in Stripe ($24.99 analyst, $59.99 desk) and + set `STRIPE_PRICE_ANALYST` / `STRIPE_PRICE_DESK`. +2. **Confirm the webhook sets `users.tier`** on `checkout.session.completed` (else the gate never opens). +3. **A Stripe test-mode key** available to the build/verification environment, so the race, + grandfather and end-to-end unlock can actually be exercised rather than asserted. + +## TAGS +VERIFIED: Stripe live + founder price objects exist (via the honest counter probe); the counter is a +cached read with no claim; founder pricing is code-gated not count-gated; the gate reads `users.tier` +via `reasoning_visible`. **CANNOT DETERMINE: whether standing price objects exist; whether the +webhook writes `users.tier`.** **BLOCKED: the entire mechanism — no Stripe credentials, so nothing on +the payment path can be verified, and it must not ship unverified.**