# Rifa Teletón — Verified implementation plan

Companion to `docs/rifa-meeting-actionable.md`. This is the meeting spec **reconciled against the
actual codebase** by a 10-agent analysis swarm (5 recon readers → 3-way §0 architecture panel →
synthesis → adversarial review), then hardened with the review's findings. Every claim below carries
file:line evidence or is marked as a decision/assumption.

Status legend: ✓ confirmed against code · ✗ spec is wrong about the code · ◐ partially true ·
⚠ launch-blocker surfaced by review (not in the meeting spec).

---

## A. What the spec got right and wrong about the current code

| Spec claim | Verdict | Evidence |
|---|---|---|
| Each raffle is an independent event, per-raffle ticket pool | ✓ | `tickets.raffleId` FK; `tickets_raffle_no_uq(raffleId,ticketNo)` schema:150; `draws.raffleId` UNIQUE schema:158 (one draw per raffle) |
| Winners drawn only from that raffle, no carry-forward | ✓ | `commit.ts:124-128` / `reveal.ts:139-147` freeze pool `WHERE raffleId`; nothing copies tickets between raffles |
| `select.ts` removes only the winning **ticket**, not the person | ✓ | `select.ts:131-141` swap-removes one `ticket_no`; has no `participantId` notion |
| No person-level win exclusion across draws | ✓ | `winners` guards `winners_prize_uq` + `winners_ticket_uq(drawId,ticketId)` are per-draw, not per-participant |
| `BOLETO_PRICE_SOLES = 5`, S/5 = 1 chance | ✓ | `orders/route.ts:44` `BOLETO_PRICE_CENTS = solesToCents(5)`; multiples enforced client + server |
| `BOLETOS_GOAL = 5000` hardcoded, not config | ✓ | `components/landing/data.ts:39` literal constant |
| `Countdown` exists but is unused | ✓ | `components/ui/Countdown.tsx` present, no import in landing |
| No carousel/slider component yet | ✓ | none in `components/ui` or `components/landing`; GSAP available |
| Prizes are DB-driven (position/name/desc/value/img/sponsor/isConfirmed) | ✓ | `prizes` schema:50-62 matches exactly |
| Prize cards have a drawn/delivered flag | ✗ | no status/drawDate column on `prizes`; delivery lives only on `winners.prizeClaimedAt` schema:188 |
| Per-prize draw date is representable today | ◐ | only derivable via the prize's raffle `drawAt`; per-prize distinct dates need a schema change |
| "money lib is duplicated" framing | ✗ correction | `lib/money.ts` exists and is used; the only real duplication is the literal `5` in `DonationForm.tsx` vs the derived `500` in the route |

**Latent bug found (orthogonal, worth fixing now):** `reveal.ts:190` draws `prizeRows.length` winners from
**live** prizes, but `commit.ts:144-150` froze `snapshotPrizeCount`/`snapshotPrizeIds` at commit. If prizes
change between commit and reveal the winner count diverges from the public commitment.

---

## B. §0 — the one real architectural change (accumulating chance pool)

**Decision: accumulating single campaign pool** (panel approach 3, hardened). Not six-raffle copy-forward.

- One `campaigns` row. The 6 sorteos stay as **6 `raffles` rows under one `campaignId`** with
  `drawOrder` 1..6 (gran final = `drawOrder` 6). This preserves `draws.raffleId` UNIQUE and the **entire
  per-draw commit/reveal/verify choreography** unchanged in shape.
- `tickets` gain `campaignId`. A ticket is a **campaign-level chance**: minted once, stays in the pool
  until it wins or is voided.
- **Carry-forward is implicit** — a losing ticket is simply still non-voided, so it reappears in every
  later draw's pool with zero copy job, zero new rows, zero carry-forward column. This eliminates the
  copy-forward model's whole failure class (up to 6 duplicated rows per chance, the refund-void fan-out
  bug, source-of-truth drift, and a carry-forward step that sits *outside* the drand commitment).
- **Person-level exclusion** lives in one new pure helper `lib/draw/pool.ts → buildDrawPool(tx,{campaignId})`:

  ```sql
  SELECT t.ticket_no FROM tickets t
  WHERE t.campaign_id = $1 AND t.voided = false
    AND t.participant_id NOT IN (
      SELECT w.participant_id FROM winners w WHERE w.campaign_id = $1)
  ORDER BY t.raffle_id, t.ticket_no
  ```

  That `NOT IN` sub-select **is** both the carry-forward and the person-exclusion. `commit.ts` and
  `reveal.ts` both call it (replacing their per-raffle queries). `select.ts` stays **byte-identical** for
  the cross-draw case — all new logic is in `buildDrawPool`.
- Winners table already carries `participantId` → **single source of truth** for exclusion, no mutable
  `excluded` flag to desync. Exclusion is set inside the same all-or-nothing winner-insert TX
  (`reveal.ts:193-272`), so the next draw's pool sees it immediately.

### Hardening grafted from the adversarial review

These are **not** in the meeting spec and materially change the §0 design:

1. **⚠ Independent verifiability must be designed in, or the new model is strictly *weaker* than today.**
   Under the old per-raffle model the candidate pool was trivially "all non-voided tickets `WHERE raffleId`"
   — reconstructable from a public count. The accumulating pool is a *derived* set (campaign tickets minus
   prior winners minus voided). Just re-hashing an operator-supplied filtered list is **circular**: a
   malicious operator could drop an honest ticket from both the published list and the published
   prior-winners and produce a self-consistent bundle that `verify.ts` marks valid.
   **Fix:** at commit, freeze and hash the **entire eligible-before-exclusion campaign ledger** plus the
   prior-winners set, both inside the commitment. Rewrite `verifyDraw` to take the full ledger + prior
   winners and **independently rebuild** `orderedTicketNos = ledger − priorWinners − voided`, recompute the
   hash, then run selection. The verifier must *derive* the candidate set, not trust it.

2. **⚠ That fix collides with Peru's Ley 29733 (datos personales).** Publishing a ticket→person linkage
   for the whole campaign (so third parties can verify "all of winner X's tickets were removed") is a
   re-identification exposure for a national charity, even pseudonymized (cluster size + purchase timing
   can re-identify). **Fix:** publish a **per-draw salted/blinded** participant commitment
   (`blindedId = HMAC(per-draw-secret, participantId)`, secret revealed at that draw's reveal only — so
   clustering is verifiable for one draw, not linkable across draws or back to identity). **Get explicit
   legal sign-off on exactly what linkage is published before building §0.** This is a launch blocker.

3. **⚠ Do not use a campaign-wide `ticket_no` counter.** A single global counter serializes *every* mint
   across the whole multi-week campaign on one row lock — a throughput/lock-timeout incident under
   Teletón live-TV traffic. The per-raffle design deliberately shards this lock. **Keep `ticket_no`
   per-raffle**; `buildDrawPool` already imposes a stable campaign-wide ordering via
   `ORDER BY raffle_id, ticket_no`. `ticket_no` does **not** need to be globally contiguous for fairness
   (selection is over an ordered distinct set). This also *shrinks* the migration — no risky renumber.

4. **Commit gains a hard sequencing gate** (`CommitError.PRIOR_DRAW_UNSETTLED`): refuse to commit draw N
   until every prior `drawOrder < N` in the campaign is verified — the exclusion set must be final before
   each snapshot. Cost: draws are strictly serial; a stuck draw blocks the chain → needs a documented
   manual void+recommit SLA tighter than the drand round cadence, since during a live 2-day Teletón a stuck
   draw is an availability risk.

5. **`prizes` keep their per-sorteo `raffleId`.** This resolves the open coupling: `commit.ts:144-147`
   freezes prizes `WHERE raffleId`, so prizes must stay attached to their sorteo's raffle row. The card's
   **draw date therefore derives from the prize's `raffle.drawAt`** — no `prizes.drawDate` column needed.
   §3 only adds `prizes.displayStatus`.

### Edge cases (all reasoned)

- Refund before win → `voided=true`, leaves every future pool automatically.
- Refund after commit, before reveal → changes `buildDrawPool` → `SNAPSHOT_MISMATCH` blocks reveal
  (correct; runbook void+recommit). `reconcileNow` must be **re-scoped per-campaign** as the pre-reveal gate.
- Refund after the person won → exclusion keyed on `winners.participantId` regardless of void → a refunded
  winner **stays excluded** (cannot un-win by refunding). If the prize is then forfeited, that needs a
  **single-prize re-draw path that does not exist today** (only remedy is void+recommit the whole draw).
- Late purchase between draws → mints a campaign ticket, appears in the next pool automatically (this *is*
  "enters the next upcoming draw").
- Late purchase by an already-excluded winner → filtered out; money accepted as a donation, chances dead →
  surface in UI/FAQ or block at checkout.
- Within-draw double prize (only possible in the many-prize gran final) → enforce one-prize-per-person via
  an optional participant-aware `select.ts` variant (this *does* touch the audited core — re-audit + verify
  reproduction tests required).
- **⚠ Participant dedupe is load-bearing.** `identityHash = sha256(normalized phone)` schema:81, `dni`
  nullable schema:78. Two phone numbers = two `participants` rows = one human can win twice, defeating the
  §0.5 hard rule. For a national charity this is a press incident, not a footnote. **Decide the dedupe key
  (DNI when present, phone otherwise) and enforce before launch.**

---

## C. Sections §1–§4 (file-precise)

### §1 Page restructure (effort L) — reorder is unblocked; final ship gated on team art/copy/PDF
- `app/(public)/page.tsx:95-117` — reorder to: Hero(logo + prize banner) → HowItWorks → PrizeGrid(dated) →
  **PricingPlans (new)** → CauseImpact → BoletosCounter → Faq (last).
- `components/landing/PricingPlans.tsx` (new) — §1.5 sales-oriented planes/precios ("cada S/5 = 1 chance",
  "compra más").
- `components/landing/PrizeSlider.tsx` (new) — §1.2 GSAP ~5s rotating slider (current prizes → previous
  winners); slots team art via props.
- `Hero.tsx` — insert the prize banner/slider above the form; keep `id="donar"` anchor.
- `CauseImpact.tsx` — verify it does **not** re-list prizes (drop the redundant third prize listing).

### §2 Urgency: configurable goal + countdown (effort M)
- `campaigns.goalBoletos` column (built in the §0 migration).
- `data.ts:39` — replace `BOLETOS_GOAL` constant with a read of `campaign.goalBoletos` + render-time fallback.
- **⚠ `readBoletosUncached` (data.ts:129-135) counts per active raffle — must count campaign-wide** (sum
  non-voided tickets `WHERE campaignId`) or it undercounts the 50k goal. (Review finding, added to scope.)
- `components/ui/Countdown.tsx` — reuse as-is, mount under the Hero with the soonest upcoming draw date.
- `app/admin/configuracion` — surface goal as an editable field.

### §3 Prize-card date + Entregado status (effort M)
- `prizes.displayStatus` pgEnum `prize_status(por_sortearse|sorteado|entregado)` default `por_sortearse`
  (date **derives from the prize's `raffle.drawAt`** — no `drawDate` column, per B.5).
- **⚠ `getLandingData` reads ONE active raffle's prizes (data.ts:156-167) — the dated grid needs a
  campaign-wide prize read** (`readCampaignPrizesUncached(campaignId)` joining all 6 raffles ordered by
  `drawOrder`, position). (Review finding, added to scope.)
- `PrizeGrid.tsx` — render the date + status tag; flip to "Entregado".
- `components/admin/PrizeManager.tsx` — status select.
- `app/api/admin/raffles/[id]/prizes/[prizeId]/route.ts:71-77` — **narrow** the 409 `DRAW_LOCKED` so
  `displayStatus` edits are allowed after commit, while `name/value/position/imageUrl` stay frozen (those
  back the reproducible verification).

### §4 Floating "Quiero participar" → form (effort S) — buildable now
- `components/landing/BottomCta.tsx` — adapt into a side-anchored floating button → `/#donar` (smooth-scroll
  is free via Lenis `anchors:true`).
- `app/(public)/layout.tsx:45-46` + `page.tsx:76` — adjust the `#persistent-cta-slot` geometry / drop the
  `pb-32` reservation if the bottom bar is replaced.
- `Hero.tsx:57` — `id="donar"` anchor already exists; no change needed.

---

## D. Build split

**Buildable now (team is not the critical path):**
- §1 section reorder (move existing components; de-dupe the prize listing in CauseImpact).
- §2 make the goal configurable (`campaigns.goalBoletos` column + rewire off the constant, fallback default).
- §2 campaign-wide boletos count + mount the existing `Countdown` (target wired once dates land).
- §3 `displayStatus` schema + admin control + PrizeGrid render + campaign-wide prize read + narrow `DRAW_LOCKED`.
- §3 fix the latent reveal-count bug (draw `snapshotPrizeCount` over `snapshotPrizeIds`; handle null legacy draws).
- §4 side-anchored "Quiero participar" CTA.
- §1.2 build the `PrizeSlider` shell (GSAP) ready to slot art.
- Hardening: pull the `5` literal in `DonationForm.tsx` into the shared `BOLETO_PRICE_CENTS`.

**Blocked on team inputs (§6):** hero art (Alex + Churchill), "Cómo funciona" copy, FAQ set, the 5 pre-draw
dates + prize per date, the literal 50k value, the restructure PDF go-ahead (gates starting the §1 reorder).

**Blocked on decisions (§7 + review):** accumulating vs copy-forward (recommend accumulating); winner removed
from ALL future draws (recommend yes); automatic into final (recommend yes); within-draw multi-prize policy
(recommend one-per-person); **the §0 fairness/privacy model** (independent-verify ledger + blinded
commitments + legal sign-off — the launch blocker); participant dedupe key (recommend DNI-then-phone).

---

## E. Sequenced build order (once go-ahead lands)

1. **[low]** §1 reorder + PricingPlans/PrizeSlider shells.
2. **[med]** §1.2 hero slider + §2 Countdown mount + inspirational image slot.
3. **[med]** §2 configurable goal + campaign-wide boletos count + shared price constant.
4. **[med]** §3 `displayStatus` + dated cuadritos (campaign-wide read) + admin + narrowed lock + reveal-count fix.
5. **[low]** §4 side-anchored floating CTA.
6. **[high]** §0 accumulating pool + person-exclusion + **independent-verify ledger + blinded commitments**
   — done last, tested hard against `verify.ts` reproduction (all 6 draws incl. multi-prize final), with
   legal sign-off on published linkage. Touches `schema.ts`, `drizzle/0004_*.sql`, `lib/draw/{pool(new),
   commit,reveal,select,verify}.ts`, `webhooks/[provider]/route.ts`, `reconcile.ts`, `data.ts`.

---

## F. Top risks

- §0 verifiability + Ley 29733 (B.1–B.2) — must be designed and legally cleared before any §0 code.
- Participant dedupe (DNI vs phone) — a real "one person wins twice" hole for a national charity.
- Strict draw sequencing — a stuck draw blocks the chain during a live event; needs a void+recommit SLA.
- No single-prize re-draw / forfeit path — a national raffle will hit an unreachable/ineligible winner.
- Seed + doc drift — `seed.ts` seeds 2 raffles (warm-up + Sep-12); `PRODUCT.md:19` / `PLAN.md` still encode
  the old two-event framing. Reconcile in the **same** change as the migration or the first re-seed breaks.
- **Mandatory test story** — golden/property reproduction tests against `verify.ts` for the accumulating +
  exclusion + within-draw-final cases are required and absent from the meeting spec.

---

## G. Decisions taken (2026-06-23)

- **Proceed scope:** build the full unblocked lane now (D, "Buildable now"), in parallel with team inputs.
- **§0 fairness model:** full verifiability via **blinded per-draw commitments** — preserve the provably-fair
  guarantee (publish a hash-committed campaign ledger with per-draw blinded HMAC participant commitments;
  `verify.ts` independently rebuilds the pool). Needs legal sign-off on Ley 29733 before any §0 code. This
  is the chosen §0 design; it is not built in the unblocked lane.

---

### Shipped in the unblocked lane (2026-06-23)

Typecheck clean, lint 0 errors, production build passes. No commit made (left for review). Migration
`drizzle/0004_fair_mikhail_rasputin.sql` is generated but **not pushed** — run `npm run db:push` (or apply
0004) against the DB before these render with real data.

- **Schema (additive, migration 0004):** `raffles.goal_boletos` (nullable) + `prizes.display_status` enum
  `(por_sortearse|sorteado|entregado)`. Both default-safe; no backfill needed.
- **§2 configurable goal:** `data.ts` reads `raffle.goalBoletos ?? DEFAULT_BOLETOS_GOAL` and returns
  `boletosGoal`; `page.tsx` feeds it to `BoletosCounter`; the `BOLETOS_GOAL` constant is gone. Set the real
  50k once §7.3 confirms (column already there). Counter still counts per active raffle — campaign-wide read
  lands with §0.
- **§2 countdown:** the unused `Countdown` now mounts under the hero, targeting the active raffle's `drawAt`.
- **§1 reorder + new sections:** Hero → **PrizeSlider** → Countdown → HowItWorks → PrizeGrid → **PricingPlans**
  → CauseImpact → BoletosCounter → FAQ. `PrizeSlider.tsx` (client, ~5s autoplay, reduced-motion-safe, slots
  team art — fed by confirmed prizes until art arrives) and `PricingPlans.tsx` (S/5 = 1 boleto, one-time, no
  subscription) are new. CauseImpact confirmed clean of prize re-listing.
- **§3 prize cards:** `PublicPrize` carries `displayStatus` + `drawDate` (date derived from the prize's raffle
  `drawAt`); `PrizeGrid` shows the date and flips the tag to "Entregado". Admin: `PrizeManager` gets a delivery
  -status select; the PATCH route accepts `displayStatus` and the `DRAW_LOCKED` guard is narrowed so status
  flips are allowed post-commit while snapshot-critical fields stay frozen (audit logs the status change).
- **§4 floating CTA:** `BottomCta` is now a side-anchored "Quiero participar" pill → `/#donar`; `pb-32` → `pb-28`.
- **Hardening:** `BOLETO_PRICE_SOLES` / `BOLETO_PRICE_CENTS` / `MAX_DONATION_*` centralized in `lib/money.ts`;
  the order route and the donation form now share one source of truth (no more "mirror the server" drift).

**Deferred (correctly):** the `reveal.ts` prizeCount bug is real but entangled with §0's within-draw selection
change (it would be two rewrites of the audited loop), and §0 rewrites `reveal.ts` anyway. Fix it there. The
live-divergence path is already blocked by the `DRAW_LOCKED` API guard, so no exposure before §0.

---

_Generated from the 2026-06-23 meeting reconciliation swarm. Recommendations are the swarm's; the §7 calls
and the fairness/privacy model are the team's + legal's to confirm._
