Developer handoff · full technical plan
The complete 16-section build plan behind the CEO summary: architecture & data flow, concept mapping, end-to-end flows, per-file producer/consumer changes, payment model, phased PR plan, rollout, testing, and the risk matrix. Source of truth: apps/docs/src/data/plan-matches-programs-openrally-full.md.
We're reviving Setpoint's parked Matches (= "Events") and Programs — finishing them with additive schema/data changes — and, in the same effort, extending OpenRally to wire them through to TPS so they appear natively in the app.
Three moves, in order:
session_* bookable family and build the TPS consumer side so Setpoint matches/programs render natively in the TPS app.Two precisions that keep the scope honest:
Status: DRAFT PLAN — for review. No code written; no repo files or branches touched. Date: 2026-06-04 · Author: Claude (cross-system design pass) · Scope: Setpoint (producer) + OpenRally (protocol) + padel-backend & mobile-app-padel (TPS consumer) Saved to: /tmp/openrally-tps-matches-programs-plan.md (temporary, per request — not committed into any repo)Wire Setpoint's Matches (open matches — the CEO's "Events") and Programs (priced rosters) to TPS over OpenRally using the exact producer→protocol→consumer pattern that already carries bookings and lessons. Nothing is reinvented: one additive, opaque, capacity-bearing bookable family on the wire (`session_*` with an opaque category discriminator), Setpoint RPCs reused, TPS edge functions copied from the coaching/lesson path, and Setpoint matches/programs rendered natively into TPS's existing Events/Matches surfaces.
The one hard gate: Setpoint's match-join / program-enroll payment path is unfinished (marks a seat *paid* the instant the Stripe PaymentIntent is *created*, with no webhook reconciliation). It must be rewritten to hold-seat → pay → confirm before any paid join is exposed — on either side. It's latent today only because Setpoint runs Stripe test keys; TPS charges real Stripe, so this becomes real money the moment TPS joins are live.
booking_type='match'; there is no separate Setpoint Events entity to build.match_* + program_* — keeps the protocol consumer-agnostic; TPS routes on the opaque category string.matches; Setpoint program → TPS native events + event_participants. Downstream code never branches on origin.external_ref). Do not couple the Setpoint live-keys cutover to this feature.lesson_*/booking_* wire shapes frozen; explicit PARKED go-ahead + DEC entries required.| Phase | Title | Repos | Goal | ||
|---|---|---|---|---|---|
A | GATE-0 (NO-GO until green): Setpoint producer payment-reconciliation fix for matches & programs | setpoint | Eliminate phantom-paid rows BEFORE any paid OpenRally path is exposed. Rewrite the match-join route from 'mark joined/paid at PaymentIntent creation' to a hold-seat -> pay -> confirm lifecycle, and add the Stripe-webhook reconciliation cases that are missing today. matches/[id]/join/route.ts must insert a NON-counting pending seat (additive join_status='pending'/'held' + new hold_expires_at column), create the PI, and return client_secret; a NEW matches/[id]/join/confirm route retrieves the PI, requires status==='succeeded', and flips the seat to joined (copy bookings/[id]/confirm/route.ts). webhooks/stripe/route.ts gains idempotent payment_intent.succeeded cases for metadata.type 'match_join' and 'program_enrollment' (copy the credit_topup flip pattern) plus a payment_failed/expired-hold sweep. Programs: keep the existing 'waitlisted'->'enrolled' confirm-enrollment route but move reconciliation into the webhook so a stranded client can't leave money taken with no seat. Additive schema only: 'pending'/'held' join_status, hold_expires_at, expired-hold sweep index. This phase is the explicit go/no-go gate; the paid protocol & consumer paths in later phases are blocked until it passes. Latent today only because Setpoint Stripe = test keys. | ||
B | Setpoint producer: additive capacity/seat-hold/external-payment schema, neutral-vocabulary emit triggers, court-less events, waitlist wiring, webhook auto-provision | setpoint | Make the producer protocol-ready and consumer-agnostic. Additive migrations only on live prod: (1) ALTER matches.booking_id DROP NOT NULL for court-less events and convert emit_match_event/emit_match_player_event INNER JOINs on bookings/courts (lines 1749, 1802) to LEFT JOIN so NULL booking_id still emits (resource_id optional). (2) Add external_payment_ref + external_payment_method_type columns + widened payment_method CHECK + consistency CHECK to match_players and program_enrollments, mirroring bookings (DEC-C). (3) Create the new producer RPCs hold_session_seat / confirm_session_seat_from_hold / cancel_session_seat (mirror hold_lesson_slot + confirm_lesson_from_hold), accepting external_payment_ref/method_type and stamping 'paid' only on confirm. (4) Translate emit_*_event payloads to FROZEN neutral vocabulary (spots_total/spots_open, level{min,max}, opaque category) and remap the orphaned match_* event names to the session_* family; add a NEW emit_program_event trigger (programs emit nothing today) emitting the same session_* names. (5) Wire promote_next_waitlisted into the cancel RPC (currently unwired). (6) Auto-provision webhook_subscriptions rows for the session_* family per facility at onboarding (and backfill booking_*/lesson_*/session_* together) so events aren't orphaned (club-673 fix). Decide the frozen wire vocabulary ONCE here, before any consumer subscribes (DEC-E). | ||
C | OpenRally protocol: additive opaque session_* family (discovery + hold + book + cancel + events), modeled file-for-file on the lesson precedent, version-bumped | setpoint | Add the opaque, capacity-bearing session_* surface to protocol/server WITHOUT teaching the protocol any typology word (DEC-A). Copy the lesson handler pair: new handlers/session_holds.rs (POST /session-holds, DELETE /session-holds/{id}) + handlers/session_bookings.rs (POST /session-bookings, GET /{id}, POST /{id}/cancel) calling the new Setpoint RPCs from Phase B; new GET /facilities/{facility_id}/sessions discovery route returning Session{ id, resource_id?, sport, category(opaque), start/end, status, spots_total, spots_open, price{amount(minor),currency}, level? } (copy availability.rs, extend with the two capacity ints + opaque category). Add session/session_hold URI fns in uri.rs reusing the generic parse_uuid_uri. Add models/session.rs (copy the lesson structs in models/coach.rs, add category/spots_total/spots_open/level/optional resource_id). Register routes in handlers/mod.rs (copy lines 94-108). Reuse db::idempotency on every mutating route (check BEFORE business logic) and the payment-block validate-against-list-price rule. Add Session/SessionHold/SessionBooking/SessionAvailability schemas to spec/openapi.yaml + schemas/*.json and the session_* rows to the SSE event-type table; bump the protocol version (existing booking_*/lesson_* shapes are FROZEN, never changed). cargo test after every DB-touching change. | ||
D | TPS consumer (padel-backend): inbound session_* webhook + forward hold/confirm/cancel edge functions + OpenRallyMatchService, dispatched via usesOpenRally | padel-backend | Mirror the lesson consumer stack file-for-file. NEW inbound edge function openrally-session-webhook (index.ts + handlers.ts, copy openrally-lesson-webhook) subscribed to the session_* family (separate webhook_subscriptions row); dual-key BTPR loop guard findSessionBtpr matching bare-uuid (reverse) AND full-URI (forward), running FIRST (DEC-F); ACK unknown event types with 200 to suppress retries. Reverse-flow handler routes on the opaque data.category: 'open-match' -> native TPS matches + ephemeral_teams writeback; 'clinic'/'league'/'camp' -> native TPS events + event_participants (+ event_wait_list when waitlisted) writeback (native-equivalence, DEC-G), with per-step compensating rollback and email-bridge identity (DEC-223) for EVERY participant. NEW forward edge functions create-session-booking-v1 (hold) + confirm-session-booking-v1 (confirm) + a get-session-availability path, plus a new OpenRallyMatchService (copy OpenRallyCoachService.createHold/confirmBooking/cancelLesson) hitting the Phase-C endpoints with idempotency keys tps-session-<id>-{hold | book | cancel}. TPS owns the real-Stripe charge and passes external_ref to confirm (DEC-C); gate the OpenRally push on paymentDetails.success=true. Store the FULL or://setpoint/session/<uuid> URI in booking_third_party_references; add the unique (platform_name, third_party_reference) index. Reuse handleExternalCancellation for inbound cancels. Extend usesOpenRally only in openRallyHelpers.ts (single-file). Net-new vs lessons: the multi-participant per-seat join/leave round-trip (DEC-G). PostHog flag tps-{ticket}-session, Lead+Security approval, mandatory linters. |
E | TPS Flutter app (mobile-app-padel): native rendering of Setpoint matches (open-match rail) & programs (Event paid-join rail), with Setpoint-origin branding | mobile-app-padel | Surface Setpoint sessions natively in the existing Play feed without a new 'OpenRally' tab (reuse-don't-reinvent). Setpoint matches (category='open-match') render via the existing IMatch/open-match rail (main_play_screen _renderUpcomingItem -> UpComingBooking, status=open -> openMatch badge; matchInfo.externalPlatform already routes to ExternalMatchDetailsScreen). Setpoint programs (category='clinic'/...) render via the native Event paid-join+waitlist rail (lib/features/community: event_action_buttons.dart Pay-to-join/Leave/Waitlist/Request-to-join, confirm-pay-event-v2 client-Stripe flow with per-merchant publishableKey swap) since the academy 'Program' screen is a dead Strapi brochure (no-op CTA) and must NOT be used. Backend feed (PlayRepository.getUpcomingEvents _type tagging) must tag Setpoint sessions as match | event so the polymorphic feed renders them. Payment reuses the existing models (server-confirm for the booking/match rail; client-Stripe sheet for the event/program rail) honoring the TPS-owns-charge decision (DEC-C). Close the Setpoint-origin branding gap in upcoming_booking.dart (_getPlatformLogoWidget/_bookingNumber have no setpoint case today). Cancel reuses cancel-booking-v1 (24h gate). PostHog flag tps-{ticket}-session 5%->25%->50%->100%, Lead+Security PR approval, flutter analyze + dart format. This phase ships LAST, consuming the public protocol, per 'user-facing comes at the end'. |
OpenRally is a stateless translator over Setpoint's Postgres. The same three-layer pattern that already carries bookings and lessons is what Matches and Programs must reuse — nothing new is invented, only an additive bookable family is added at each layer.
SETPOINT (producer) OPENRALLY (protocol, Rust/Axum) TPS (consumer) apps/admin + Postgres protocol/server (opaque wire) padel-backend + Flutter ┌──────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────┐ │ matches / match_players│ RPC │ handlers/*.rs (hold->book)│ HTTP │ create-*-booking edge fns │ │ programs / program_enr │<────▶│ uri.rs (or://setpoint/..) │<──────▶│ OpenRally*Service (hold) │ │ bookings (court anchor)│ │ models/*.rs (req/resp) │ │ booking_third_party_refs │ │ │ │ reuses Setpoint RPCs only │ │ usesOpenRally(club) gate │ │ emit_*_event triggers │ │ │ │ │ │ │ │ │ GET /facilities/.../... │ │ native Events / Matches │ │ ▼ │ │ POST /<family>-holds │ │ native event_participants│ │ or_events (outbox) ──┼─────▶│ POST /<family>-bookings │ │ native matches/teams │ │ │ fan_out trigger │ SSE │ GET /stream (PgListener)│ │ │ │ ▼ │ └───────────────────────────┘ │ inbound webhook edge fn │ │ webhook_subscriptions │ ▲ wire = OPAQUE │ (BTPR loop-guard + │ │ │ (per event fam.) │ │ typology string │ native writeback) │ │ ▼ │ webhook │ never "match"/"program" │ ▲ │ │ webhook_deliveries ───┼──────────────┼──────────────────────────────┼──────┘ │ │ (pg_cron worker) │ POST to consumer's webhook URL │ │ └──────────────────────┘ └──────────────────────────┘
Key invariants this blueprint preserves:
tps_*. The wire carries one opaque typology string (category) the protocol stores and echoes but never branches on.create-coaching-booking-v2/openrally-lesson-webhook; each native render uses existing TPS Events/Matches tables.events row + event_participants. Downstream code never branches on origin.ALTER ... DROP NOT NULL / new CHECK value / new RPC / new trigger — never destructive. Protocol changes are new routes + new schemas + version bump; existing lesson_* / booking_* shapes are frozen.session_*)After self-debate (two parallel match_*/program_* protocol families vs one opaque family), the blueprint mints ONE opaque, capacity-bearing family — `session_*` carrying an opaque category passthrough string. Rationale:
REJECTED: two protocol families CHOSEN: one opaque session_* family ┌─────────────────────────────┐ ┌─────────────────────────────────┐ │ match_* + program_* on wire│ │ session_* on wire (single) │ │ - leaks typology to protocol│ │ + category:"open-match"/"clinic"│ │ - consumer branches on origin│ │ (opaque passthrough only) │ │ -> breaks native-equiv │ │ - protocol never learns typology│ │ - 2x webhook/onboarding rows │ │ - consumer routes by category │ │ - violates consumer-agnostic │ │ - 1 webhook family, 1 onboarding│ └─────────────────────────────┘ └─────────────────────────────────┘ industry check: Stripe payment_intent + opaque metadata; OpenTravel Reservation + type code -> generic entity + opaque classifier is the dominant pattern. CHOOSE one family.
The internal match_* names already in emit_match_event are NOT frozen (no consumer subscribes — orphaned producer) and are remapped to session_* at the adapter boundary, exactly as BookingRow::payment() collapses Setpoint's wider payment_status set to the protocol enum. Internally a session resolves to a matches row OR a programs row via a thin typology header (nullable mapping FKs, one-of CHECK). The wire word is session; the typology header word (Event/Program) lives only in Setpoint admin.
Bookings/lessons are single-seat. Matches/programs are multi-seat, so the additive surface carries capacity and a per-seat hold->pay->confirm. Capacity already exists natively (matches.max_players/current_players, maintained by update_match_player_count; programs.max_participants + enrolled count) so spots_total/spots_open is a pure adapter computation — but the seat-hold RPC does not exist and the join route is unsafe (Section 6 / Phase A gate).
join via TPS: discover ── hold seat ─────── pay (TPS Stripe) ── confirm (external_ref) ── reconcile
GET /sessions POST /session- [TPS owns charge, POST /session-bookings webhook
spots_open>0 holds real Stripe acct] payment{external_ref} session_joined
│ │
▼ RPC hold_session_seat ▼ RPC confirm_session_seat_from_hold
match_players.join_status='held' flip 'held'->'joined',
(additive enum) + hold_expires_at payment_method='external',
(additive col), seat reserved external_payment_ref stamped,
NOT counted as 'taken' current_players++ (trigger)The CEO's "Events" == Setpoint booking_type='match' == the Matches feature. "Programs" is the separate priced-roster feature. Both surface to the protocol as one opaque session with a category discriminator; TPS routes on category to its existing native tables.
| Setpoint producer (admin/header) | OpenRally wire (opaque) | TPS consumer (native render) | Notes |
|---|---|---|---|
Matches feature = booking_type='match' (the CEO's "Events"); matches + match_players | session, category="open-match" | TPS matches + ephemeral_teams/teams (4 seats, free or split); open-match rail (is_open=true) | matches.booking_id is the court anchor; goes NULLABLE for court-less events |
Programs feature = priced roster; programs + program_enrollments | session, category="clinic"/"league"/"camp" (opaque, from program_type) | TPS events + event_participants + event_wait_list + event_payment_details | TPS events (priced, capacity, waitlist) is the Program analog — NOT TPS matches |
matches.max_players / current_players | spots_total / spots_open (computed) | events.max_participants / roster count; or matches 4-seat render | capacity already native both sides |
match_players row (per-seat) / program_enrollments row | one seat in session (join/leave) | event_participants row / team slot player_one_id | per-seat payment lives here |
programs waitlist (program_enrollments.status='waitlisted', waitlist_position, promote_next_waitlisted) | spots_open==0 -> waitlist seat; session_spot_opened event | event_wait_list (manual promote) | Matches have NO waitlist; Programs do |
cost_per_player_amount (match) / programs.price_amount (minor units) | price{amount(minor),currency} + payment block | events.price(MAJOR units *100) / split | units mismatch TPS major vs Setpoint minor — convert at boundary |
per-seat payment via payment_method='external' + external_payment_ref | OpenRally v1.1 payment{status,method_type,external_ref,amount,currency} | TPS-owned real Stripe PI id (opaque to Setpoint) | Setpoint NEVER charges on OpenRally path |
match_type(competitive/friendly) / program_type(clinic/league/...) | category opaque string (passthrough) | TPS chooses match-vs-event rail + tournament_type | protocol never interprets it |
Anti-mapping (do NOT do): a Setpoint match -> TPS events (events have no fixed-seat team model); a Setpoint program -> TPS matches (matches have no capacity/waitlist/payment-ledger). The CEO word "events" collides with TPS's priced events table, which is the Program analog — keep the wire opaque so this naming collision never leaks.
All flows reuse the booking/lesson scaffolding. <family> = session; idempotency keys = tps-session-<id>-{hold|book|cancel}; reference key = full URI or://setpoint/session/<uuid> in booking_third_party_references.third_party_reference.
TPS get-*-availability (new openrally-session path)
└─ GET /openrally/v1/facilities/{facility_id}/sessions?date=YYYY-MM-DD&sport=&category=
X-API-Key: club.clubs_3rd.config.api_key
<- 200 { sessions:[ { id:"or://setpoint/session/{uuid}", facility_id, resource_id?(NULL court-less),
sport, category(OPAQUE), title?, start_time, end_time, duration_minutes,
status: open|full|cancelled|completed, spots_total, spots_open,
price{amount(minor),currency}, level?{min,max} } ] }
Producer: SELECT open matches/programs; spots_open = max_players-current_players
(or max_participants - enrolled count). resource_id NULL when booking_id NULL. TPS create-session-booking-v1 (copy create-coaching-booking-v2)
detect usesOpenRally(club) -> OpenRallyMatchService.createHold
└─ POST /openrally/v1/session-holds X-OpenRally-Idempotency-Key: tps-session-{bookingId}-hold
{ session_uri:"or://setpoint/session/{uuid}", hold_duration_minutes?, consumer_id:"tps:user:{id}",
consumer_type:"booking_app" }
<- 201 { id:"or://setpoint/session_hold/{uuid}", session_id, status:"active", expires_at,
ttl_seconds, spots_open }
Producer RPC hold_session_seat(session_id, hold_minutes): row-lock match/program FOR UPDATE,
spots_open>0 else 409, INSERT match_players(join_status='held')/program_enrollments(status='held')
+ hold_expires_at. Emits session_hold_created (is_durable=false).
TPS writes BTPR{status:'hold', third_party_reference=session URI, is_master_record:true,
config:{ session_hold_id, slot_uri?, openrally_session_id, booking_kind:'session', category }}
+ native pending row (coach_reservations-equivalent or event_participants joined=false).
On ANY failure -> compensating rollback frees BTPR + native rows + releases hold. TPS owns the charge (real Stripe, TPS account) — Setpoint TEST keys never invoked on this path.
Court/event-style: TPS create-payment-intent / confirm-pay-* (PromptPay or card, client/server)
paymentDetails.success=true ──► gate
└─ confirm-session-booking-v1 (copy confirm-coaching-booking-v2)
POST /openrally/v1/session-bookings X-OpenRally-Idempotency-Key: tps-session-{bookingId}-book
{ hold_id:"or://setpoint/session_hold/{uuid}", customer_name, customer_email, customer_phone,
payment:{ status:"paid", method_type:"prepaid", external_ref:<TPS Stripe PI id>,
amount, currency } }
<- 201 { id:"or://setpoint/session/{uuid}", facility_id, category, start_time, end_time,
status, spots_total, spots_open, payment, created_at }
Producer RPC confirm_session_seat_from_hold(hold_id, customer, payment_status, external_ref, method_type):
flip 'held'->'joined'/'enrolled', set payment_method='external', external_payment_ref,
external_payment_method_type; current_players++ via update_match_player_count.
Stamp 'paid' ONLY here (never at PI creation). Emits session_joined (durable).
TPS: UPDATE BTPR status hold->confirmed; flip native pending->confirmed; native confirmation email.For a Setpoint-native join (admin creates inside Setpoint app), the producer-side hold->pay->confirm uses Setpoint's own (test) Stripe and the fixed defect flow (Phase A): hold seat -> PI -> client confirm -> webhooks/stripe reconcile by external_ref. The two payment owners never share a Stripe account.
Setpoint admin creates/fills a match or program (native UI)
emit_*_event -> or_events(session_*) -> fan_out_or_event (gated by webhook_subscriptions.event_types[])
-> webhook_deliveries (pg_cron worker) -> POST consumer webhook URL
TPS openrally-session-webhook (copy openrally-lesson-webhook index.ts + handlers.ts)
headers X-OpenRally-Event: session_created|session_joined|...; body { id,type,created,
facility_id, entity_id:"or://setpoint/session/{uuid}", data:{ category, spots_total, spots_open,
start_time, end_time, primary_player_email, price_per_pax_amount, currency, ... } }
DUAL-KEY loop guard (findSessionBtpr): match BARE uuid (reverse, origin='admin_writeback')
AND full URI (forward, app-booked); skip if owned -> ack 'tps_owns_creation'.
Route on data.category:
category="open-match" -> write TPS matches + ephemeral_teams (native open-match render)
category="clinic"/... -> write TPS events + event_participants (+ event_wait_list if waitlisted)
Per-step compensating rollback (no supabase-js txns). Identity by email (DEC-223).
Insert BTPR(bare uuid, origin='admin_writeback', config.booking_kind='session', category). TPS-initiated cancel:
cancel-booking / leave path -> OpenRallyMatchService.cancelSeat
POST /openrally/v1/session-bookings/{uuid}/cancel Idem: tps-session-{id}-cancel { reason }
<- { id, status:"cancelled", refund_eligible, free_cancellation_hours, hours_until_start }
Producer RPC cancel_session_seat: release seat (spots_open++); for programs trigger
promote_next_waitlisted (currently UNWIRED — Phase B wires it). Emits session_left + session_spot_opened.
TPS UPDATE BTPR status='cancelled'; refund owned by TPS (it made the charge).
Setpoint-initiated cancel (session_cancelled / session_left):
webhook -> findSessionBtpr -> handleExternalCancellation (the GENERIC third-party cancel path,
shared with Playtomic/Kross/lessons) -> marks BTPR cancelled, cancel-booking-v1 as admin,
native push titled "Match/Event Cancelled" (Setpoint label hidden), idempotent if already cancelled. or_events row (session_*) ─ fan_out_or_event_trigger ─► fan_out_or_event
WHERE NEW.event_type = ANY(s.event_types) AND (s.facility_id IS NULL OR = NEW.facility_id) AND s.active
── match? ──► webhook_deliveries (pg_cron worker v0.27.2) AND SSE or_events_channel
── no row matching session_* ──► SILENTLY DROPPED (club-673 failure mode)
FIX (Phase B): auto-provision webhook_subscriptions per facility at onboarding for ALL families:
booking_* (existing), lesson_* (existing), session_* (NEW)
{ partner_name, url:<consumer session webhook>, event_types:{session_created,session_filled,
session_cancelled,session_completed,session_joined,session_left,session_spot_opened}, facility_id, active:true }
(hold events session_hold_created/released are non-durable, SSE-only, not delivered by webhook.)DEC-A: One opaque `session_*` family + `category`, NOT parallel `match_*`/`program_*` on the wire. Rejected (two families): leaks typology into the protocol, forces consumers to branch on origin (breaks native-equivalence), doubles webhook/onboarding surface, violates consumer-agnostic. Chosen: one generic capacity-bearing entity + opaque classifier (Stripe payment_intent+metadata, OpenTravel Reservation+type-code precedent). The already-emitted internal match_* names are orphaned (no subscriber) so they are NOT frozen and are safely remapped to session_* at the emit_*_event boundary — same collapse pattern as BookingRow::payment(). Wire word `session` chosen over `event` because event collides with the or_events outbox table; needs CEO/lead sign-off on the final noun.
DEC-B: Payment-reconciliation fix on the Setpoint producer is a HARD GO/NO-GO gate BEFORE any paid path (Phase A). The match-join route (matches/[id]/join/route.ts) inserts match_players with join_status:'joined', paid_amount, payment_method:'stripe' the instant stripe.paymentIntents.create() returns (lines 227-242) — before the card confirms — and webhooks/stripe/route.ts has NO case for metadata.type:'match_join' (set at line 216) so the succeeded/failed event is silently dropped. Programs park correctly at 'waitlisted' but also have no webhook net (type:'program_enrollment' unhandled). Rejected (ship paid path on the existing flow, fix later): produces phantom-paid rows that increment current_players and show a match "full" with unpaid seats; only latent because Setpoint Stripe = test keys. Chosen: rewrite to hold-seat -> pay -> confirm + webhook reconcile (copy bookings/[id]/confirm/route.ts + the credit_topup flip pattern) FIRST. No paid OpenRally path ships until this gate is green. No-silent-errors principle: the dropped webhook is exactly the banned log-and-continue.
DEC-C: TPS owns the charge on the OpenRally path; Setpoint RECORDS a consumer-settled payment via the v1.1 `payment` block. This is already proven live for bookings/lessons: bookings.external_payment_ref is "opaque consumer-supplied... Setpoint never interprets this string"; confirm_lesson_from_hold flips payment_method='external' only when both external_payment_ref + external_payment_method_type are supplied. Rejected (Setpoint charges via its own Stripe for protocol joins, or TPS delegates charging to Setpoint): would force Setpoint test-keys into a real-money path and split merchant-of-record ambiguously. Chosen: Setpoint's test keys are invoked ONLY for native-Setpoint-app joins; OpenRally-originated joins record (never charge) method_type='prepaid' + opaque external_ref. Resolves the test-vs-live concern — the two never share a Stripe account. Requires additive match_players/program_enrollments columns mirroring bookings (external_payment_ref, external_payment_method_type, widened payment_method CHECK, consistency CHECK).
DEC-D: `matches.booking_id` goes NULLABLE for court-less events; emit triggers must tolerate NULL. emit_match_event (line 1749) and emit_match_player_event (line 1802) currently INNER JOIN bookings/courts via m.booking_id; a NULL booking_id would drop the row from the join and emit nothing. Rejected (require every match to anchor a court): blocks the CEO's court-less "events" use case. Chosen: additive ALTER ... DROP NOT NULL + convert the emit-trigger joins to LEFT JOIN so resource_id/court_uri become optional in the session payload. Additive-safe.
DEC-E: Frozen neutral capacity/level wire vocabulary, decided ONCE before any consumer subscribes. emit_match_event leaks raw Setpoint column names (max_players, current_players, min_level, max_level, match_type) into or_events. Rejected (ship the raw names): they become a frozen contract the moment TPS subscribes (the lesson_* lesson), permanently coupling the wire to Setpoint internals and naming match_type typology on the wire. Chosen: translate at the emit/adapter boundary to neutral spots_total/spots_open, level{min,max}, opaque category — finalize the schema before the first session_* subscription exists.
DEC-F: Dual-key BTPR loop guard + reference-key URI format are mandatory, copied from lessons. A session owned by TPS exists under TWO keys: forward (app-booked) = full or://setpoint/session/<uuid> URI; reverse (admin writeback) = bare uuid + origin='admin_writeback'. findSessionBtpr must match both (.in([bareUuid, fullUri])) and run FIRST in the create handler. Rejected (single-key dedup): the TPS->Setpoint->TPS round-trip double-books. Chosen: copy findLessonBtpr. Add the unique index (platform_name, third_party_reference) on the TPS side to activate the 23505 idempotency fallback (latent today — only the read-guard dedups). Reference stores the FULL URI (not bare uuid) so loop-guard + cancel path match, exactly as court bookings do.
DEC-G: Multi-participant seat round-trip is net-new — the lesson precedent is INSUFFICIENT. add-coaching-players is TPS-native-only (fixed extra_player_id_1/2/3, hard 4-cap, no OpenRally call, no BTPR write). Rejected (reuse the coaching multi-player model): it never round-trips seats, so Setpoint would never see TPS-added participants and vice-versa. Chosen: a real participant-rows model (one BTPR/seat or a participant child table) with forward+reverse join/leave webhook contracts (session_joined/session_left/session_spot_opened) so every seat propagates both ways. This is the genuinely new design surface vs bookings/lessons.
DEC-H: Seat-hold lives on the seat row (lesson pattern), with idempotency on mutating routes. Rejected (a protocol-level or_session_holds table like or_holds): lessons proved hold state on the entity row (status='held') is sufficient and simpler; court-less events have no or_holds analog anyway. Chosen: match_players.join_status='held' / program_enrollments.status='held' (additive enum values) + hold_expires_at + a pg_cron expired-hold sweep (mirror idx_bookings_expired_holds). Reuse db::idempotency on every mutating session-* route regardless (the holds.rs/bookings.rs discipline: idempotency check BEFORE business logic). current_players (public "taken") advances only on confirm; held seats reserve capacity for double-sell safety without showing "full".
Scope ordering (per principle 2): consumer-side first, admin-side second, protocol last — BUT the producer payment-safety gate (DEC-B) and the producer seat-hold RPC (DEC-H) are prerequisites that must land before the paid protocol/consumer paths, which is why the phases below are producer-first.
Scope of this section. Everything below is producer-side (Setpointapps/admin+setpoint/supabase) and protocol-side (setpoint/protocol/server). It is the part of the plan that must land before any paid consumer path. Phases A and B (the payment-reconciliation gate and the additive capacity/seat-hold/emit schema) are producer-only; phase C is the protocol surface. The consumer (TPS) work in §7–§8 is blocked on §6.1 going green. PARKED go-ahead. Matches, Events, and Programs are flagged PARKED — do not modify without explicit go-ahead insetpoint/CLAUDE.md→ "Current Direction". The CEO has now given that go-ahead to wire them. Every work item here is undertaken under that explicit unpark; the PR description must state it. Migration discipline (applies to every migration below). Production is LIVE (real Pad Thai Padel customers + live OpenRally consumers). Per.claude/rules/migrations.md: additive / reversible only — no destructiveALTER/DROPon live data; widenCHECKs by drop-and-recreate-with-superset (never narrow); new enum values, new columns (nullable or defaulted), new RPCs, new triggers, new indexes only. Author every file withsupabase migration new <desc>(never hand-roll the timestamp). Deploy is the single approved path:supabase db query --linked --file supabase/migrations/<file>.sqlon NEW files only. Nodb push/db reset --linked. DEC logging. Every non-trivial decision below (DEC-A … DEC-H from §5) is logged toapps/docs/src/data/decisions.ts(context, decision, rejected alternatives, rationale) as part of the chore PR that opens this work, persetpoint/CLAUDE.md→ "Engineering Mindset". Wire-vocabulary and thesession_*family noun (DEC-A, DEC-E) are decided once, here, before any consumer subscribes — they become a frozen contract the moment TPS subscribes.
This is a hard GO / NO-GO gate. No paid OpenRally path (protocol session-bookings, TPS forward flow) ships until 6.1 is green. The defect is latent today only because Setpoint Stripe runs on test keys in prod (intentional — see setpoint/CLAUDE.md → "Known Issues"); it becomes real money the instant a consumer settles a charge against a Setpoint seat.
The defect (verified against live source):
apps/admin/src/app/api/v1/matches/[id]/join/route.ts — for a split/paid join, calls stripe.paymentIntents.create() (line 209, metadata.type:"match_join" at line 216) and then immediately inserts the match_players row with join_status:"joined", payment_method:"stripe", paid_amount, joined_at:now() (lines 227–242) — before the card confirms. The update_match_player_count trigger then increments matches.current_players off that phantom row, so a match can read "full" with unpaid seats.apps/admin/src/app/api/v1/webhooks/stripe/route.ts — the switch (event.type) (line 42) has no case for metadata.type === "match_join", and the payment_intent.succeeded arm only handles lesson_package + credit_topup. The succeeded/failed event is silently dropped (exactly the banned log-and-continue per the no-silent-errors principle).apps/admin/src/app/api/v1/programs/[id]/enroll/route.ts — *mostly* correct (parks paid enrollments at status:"waitlisted", line 85; confirm-enrollment/route.ts flips to enrolled only after paymentIntents.retrieve() shows succeeded). But there is no webhook net: metadata.type === "program_enrollment" (set at enroll/route.ts:164) hits no case in the Stripe webhook, so a client that never calls confirm-enrollment leaves money taken and the seat stranded as waitlisted forever.Itemized work:
..._match_seat_hold_state.sql:match_players.join_status CHECK (currently {invited, joined, declined, cancelled} at 00000000000000_initial_schema.sql:3860): drop and recreate as the superset {invited, joined, declined, cancelled, pending} (additive value only).ALTER TABLE match_players ADD COLUMN hold_expires_at timestamptz (nullable; mirrors bookings.hold_expires_at at :3157).idx_match_players_expired_holds ON match_players (hold_expires_at) WHERE join_status = 'pending' (mirror idx_bookings_expired_holds at :6624) for the sweep.update_match_player_count trigger keeps counting `joined`-only for matches.current_players (the public "taken" count). The seat-hold reservation against capacity is enforced in the RPC (§6.2.3) by counting pending + joined under the row lock — so a held seat reserves capacity for double-sell safety without advancing the public "full" count (DEC-H).bookings/[id]/confirm/route.ts lifecycle):match_players with join_status:"pending", hold_expires_at = now() + TTL, payment_method:NULL, paid_amount:NULL. Create the PaymentIntent (metadata.type:"match_join"). Return { client_secret, player_id }. The seat is reserved but not counted (no phantom-paid row).bookings/[id]/confirm/route.ts:42–94):stripe.paymentIntents.retrieve(payment_intent_id); require `status === "succeeded"`. Flip the pending row → join_status:"joined", payment_method:"stripe", paid_amount, joined_at:now(), clear hold_expires_at. Insert a payments row { type:"match", reference_id:player_id, stripe_payment_id, status:"succeeded" }. Idempotent guard on an already-joined row (return the existing seat).credit_topup flip pattern at lines 668–700 — idempotent lookup-by-id → flip):payment_intent.succeeded, metadata.type === "match_join": look up the pending match_players row by payment_intent_id; if still pending, perform the same confirm as route #3 (safety net for a client that never calls confirm). Idempotent.payment_intent.succeeded, metadata.type === "program_enrollment": perform the same waitlisted → enrolled + payments insert that confirm-enrollment/route.ts:60–84 does, keyed on stripe_payment_intent_id. Idempotent. Reconciliation moves into the webhook so a stranded client can't leave money taken with no seat.payment_intent.payment_failed / canceled: delete/cancel the matching pending seat so it doesn't linger.pg_cron job in a migration (built-in-first; mirror the bookings expired-hold pattern): periodically delete/cancel match_players where join_status = 'pending' AND hold_expires_at < now(), freeing the reserved capacity.enroll (park-at-waitlisted) + confirm-enrollment flip routes unchanged; the only producer change for programs in this phase is the webhook net in item 4. (Programs already follow hold → pay → confirm; they just lacked the safety net.)Exit criterion (the gate): an abandoned/failed PaymentIntent leaves zero phantom-paid rows and zero stranded-paid enrollments; current_players only advances on a confirmed (paid or pay-at-club) seat. Until this is demonstrably true, phases C/§7/§8 do not ship.
matches + additive capacity / seat-hold / external-payment schema (Phase B)The CEO's "Events" == booking_type='match' == the Matches feature (verified: "Events" is the display label for booking_type='match', not a separate feature). The Event concept is modeled as a thin typology header over `matches`, not a new table — preserving native-equivalence and reusing match_players for seats.
6.2.1 — Court-less events: `matches.booking_id` NULLABLE (DEC-D). Migration ..._matches_event_typology_header.sql:
ALTER TABLE matches ALTER COLUMN booking_id DROP NOT NULL (currently NOT NULL at 00000000000000_initial_schema.sql:3913). Additive-safe.ADD COLUMN typology text NOT NULL DEFAULT 'match' with CHECK typology = ANY('{match, event}') — the admin-only header word. Default keeps every existing row valid. (The wire never sees this word; see DEC-A.)ADD COLUMN program_id uuid REFERENCES programs(id) and ADD COLUMN coaching_session_id uuid REFERENCES coaching_sessions(id), both nullable, guarded by a one-of CHECK (num_nonnulls(program_id, coaching_session_id) <= 1). This lets one matches-rooted "session" resolve to a matches row OR (via the header) a programs/coaching row at the adapter boundary, without a second roster table.emit_match_event (:1749–1753) and emit_match_player_event (:1799–1803) currently INNER JOIN bookings/courts via booking_id; a NULL drops the row from the join and emits nothing. Convert both to LEFT JOIN so court_id/court_uri/resource_id become optional in the payload and a court-less event still emits. This is folded into the emit rewrite in §6.3 (the same migration replaces the function bodies).6.2.2 — External-payment columns mirroring `bookings` (DEC-C). Same migration, applied to both match_players and program_enrollments:
ADD COLUMN external_payment_ref text (opaque consumer-supplied reference, e.g. a TPS Stripe PI id — Setpoint never interprets it; mirror bookings.external_payment_ref at :3177, comment :3191).ADD COLUMN external_payment_method_type text with CHECK = ANY('{prepaid, pay_at_venue, comp, credit, other}') (the OpenRally v1.1 payment.method_type enum; mirror bookings :3181).match_players.payment_method CHECK from {stripe, pay_at_club} (:3861) to the superset {stripe, pay_at_club, external} (additive value).bookings_external_payment_consistency_check (:3180): payment_method = 'external' requires both external_payment_ref and external_payment_method_type non-null.... (external_payment_ref) WHERE external_payment_ref IS NOT NULL on each (mirror idx_bookings_external_payment_ref at :6628).match_players has no payment_status column (unlike bookings). For the consumer-settled path the charge is owned by the consumer, so refund is the consumer's concern; cancellation is represented on the seat as join_status:'cancelled'. If a producer-visible refund state is later required, it is a separate additive column — out of scope for this phase.6.2.3 — New producer seat-hold RPCs (migration ..._session_seat_openrally_rpcs.sql, mirroring confirm_lesson_from_hold / hold_lesson_slot / cancel_lesson_booking from 20260513095629_coach_booking_openrally_rpcs.sql + 20260508164245_coach_court_reservation.sql):
hold_session_seat(p_session_id uuid, p_hold_minutes int) — row-lock the matches/programs row FOR UPDATE; compute spots_open from pending + joined vs max_players (or max_participants); if spots_open <= 0 raise a conflict (mapped to 409 in the protocol). INSERT a held seat (match_players.join_status='held' / program_enrollments.status='held') + hold_expires_at; return hold_id + expires_at. Emits session_hold_created (non-durable).match_players.join_status and program_enrollments.status to carry an additive 'held' value. (§6.1 already added 'pending' to match_players for the native-app flow; 'held' is the protocol flow's hold value — add both additively; semantics: pending = native Setpoint-app PI in flight, held = OpenRally consumer hold. Both are non-counting.)confirm_session_seat_from_hold(p_hold_id, p_customer, p_payment_status, p_external_payment_ref, p_external_payment_method_type) — flip 'held' → 'joined'/'enrolled'; set payment_method='external' only when both external_payment_ref + external_payment_method_type are supplied (exact rule from confirm_lesson_from_hold at 20260515084759:97–103); current_players++ via the existing update_match_player_count trigger. Stamp `paid` only here (never at PI creation). Emits session_joined (durable).cancel_session_seat(p_session_id, p_seat, p_reason) — release the seat (spots_open++); for programs, wire `promote_next_waitlisted(p_program_id)` (it exists at 00000000000000_initial_schema.sql:2536 but is unwired on cancel today). Emits session_left + session_spot_opened (durable).release_session_hold(p_hold_id) — drop the held seat; emits session_hold_released (non-durable).All RPCs SECURITY DEFINER, search_path = public, row-lock the parent before mutating capacity (consistent with the booking row-locking rule).
or_events + match_* payload translation to a consumer-agnostic shape (Phase B, DEC-A / DEC-E)Programs emit nothing today (no emit_program_event trigger exists — orphaned producer). Matches *do* emit, but under internal match_* names that leak raw Setpoint column names (max_players, current_players, min_level, max_level, match_type) straight into or_events (:1756–1771, :1819–1831). Both are fixed at the emit/adapter boundary, in the same migration that does the LEFT-JOIN conversion (§6.2.1).
The `match_*` names are NOT frozen — no consumer subscribes to them (orphaned producer), so they are safely remapped to session_* at the emit boundary, exactly as BookingRow::payment() collapses Setpoint's wider payment_status set to the protocol enum. (Confirm no non-TPS consumer is already subscribed to match_* before remapping.)
Itemized work:
session_created, session_filled, session_cancelled, session_completed, session_joined, session_left, session_spot_opened, plus non-durable session_hold_created / session_hold_released.spots_total (:= max_players), spots_open (:= max_players - current_players), level:{min, max} (:= min_level/max_level), category (opaque passthrough derived from match_type/program_type, never interpreted), resource_id (optional — omitted/NULL for court-less events via the LEFT JOIN), start_time/end_time, status, and the v1.1 `payment` block ({status, method_type, external_ref, amount, currency}, appended only when external_payment_ref IS NOT NULL, omitted otherwise — copy emit_booking_event's conditional payment object at :1663–1700). The raw max_players/current_players/min_level/max_level/match_type keys are removed from the wire payload.bookings/courts joins to LEFT JOIN (§6.2.1) so a NULL booking_id still emits with resource_id optional.programs + a player trigger on program_enrollments, emitting the same `session_*` family with the same neutral payload (spots_total := max_participants, spots_open := max_participants - count(enrolled), category := opaque program_type, optional resource_id from programs.court_id). Programs carry a waitlist; matches do not — a waitlisted enrollment maps to a spots_open == 0 state and session_spot_opened fires on promotion.Event/Program/match/clinic/league) live only in Setpoint admin. category is an opaque string the protocol stores and echoes but never branches on. session was chosen over event because event collides with the or_events outbox table; the final noun needs CEO/lead sign-off (logged as the open item under DEC-A).session_* family, version-bumped (Phase C, DEC-A / DEC-H)No match/program/event surface exists in protocol/server today (verified: router, uri.rs, openapi.yaml, and all schemas/*.json carry zero such vocabulary). The lesson family is the precedent — a bookable type added additively without the protocol learning the domain word. Mirror it file-for-file, teaching the protocol only session + an opaque category. Existing booking_* / lesson_* shapes are FROZEN — never changed. cargo test after every DB-touching change.
Itemized work (each a copy of the lesson analog):
pub fn session(id) → or://setpoint/session/{uuid} and pub fn session_hold(id) → or://setpoint/session_hold/{uuid}, reusing the generic parse_uuid_uri(uri, "session") / ..."session_hold") (copy the lesson/lesson_hold fns). No slot-URI materialization — matches/programs are pre-created rows, so the session URI alone is the discovery key.src/models/coach.rs (LessonHoldRequest/Response, LessonBookingRequest/Response, LessonCancelRequest/Response, PaymentBlock) → Session* equivalents, adding category: String (opaque), spots_total: i32, spots_open: i32, level: Option<{min,max}>, resource_id: Option<String> (NULL for court-less events).lesson_holds.rs) — POST /openrally/v1/session-holds, DELETE /openrally/v1/session-holds/{id}. Calls the Phase-B RPCs hold_session_seat / release_session_hold. Keep the hold-minutes clamp (DEFAULT_HOLD_MINUTES + MIN/MAX) and map_hold_error → 409 Conflict on spots-full.lesson_bookings.rs) — POST /openrally/v1/session-bookings, GET /{id}, POST /{id}/cancel. Calls confirm_session_seat_from_hold / cancel_session_seat. The confirm response includes spots_total/spots_open/category. Reuse the payment-block validate-against-list-price rule and find_or_create_customer_by_email. The payment block is the authoritative settlement for protocol-originated joins: method_type='prepaid' + opaque external_ref (the consumer's Stripe PI) → Setpoint records, never charges (DEC-C). Setpoint's own test keys are invoked only on native-Setpoint-app joins.GET /openrally/v1/facilities/{facility_id}/sessions?date=&sport=&category= (copy availability.rs, extend with the two capacity ints + opaque category). Returns Session{ id:"or://setpoint/session/{uuid}", facility_id, resource_id?, sport, category(opaque), title?, start_time, end_time, duration_minutes, status:open|full|cancelled|completed, spots_total, spots_open, price{amount(minor),currency}, level?{min,max} }. resource_id NULL when booking_id NULL. Producer computes spots_open = max_players − current_players (or max_participants − enrolled).db::idempotency on every mutating session-* route — the idempotency check runs before business logic (the holds.rs/bookings.rs discipline). Hold state lives on the seat row (join_status='held'), not a protocol table — the lesson pattern, sufficient and simpler; court-less events have no or_holds analog anyway.Session, SessionHold, CreateSessionHoldRequest, CreateSessionBookingRequest, SessionBooking, SessionAvailabilityResponse schemas to spec/openapi.yaml + schemas/*.json (copy Slot/Hold/CreateHoldRequest/CreateBookingRequest/Booking/AvailabilityResponse, adding spots_total/spots_open/category). Add the session_* rows to the SSE event-type table. Bump the protocol version (booking_*/lesson_* shapes unchanged). Update apps/docs/src/data/versions.ts + the OpenRally PRD per the docs-sync checklist.webhook_subscriptions auto-provisioning of the session_* family per facility (Phase B, club-673 fix)fan_out_or_event (00000000000000_initial_schema.sql:1968) only creates a webhook_deliveries row when a webhook_subscriptions row lists NEW.event_type in event_types[] AND (facility_id IS NULL OR = NEW.facility_id) AND active. No subscription lists any `session_*`/`match_*` event today, so every emitted match event is written and then silently dropped (the club-673 failure mode; setpoint/CLAUDE.md flags this as an open TODO: "auto-provision both on onboarding"). Programs would be identically orphaned the moment §6.3 wires their emit.
Itemized work:
webhook_subscriptions rows for all three families together (booking_* and lesson_* exist; `session_*` is new), closing the manual-split gap that shipped club 673 without cancels. The session_* row:{ partner_name, url: <consumer session webhook>, event_types: {session_created, session_filled, session_cancelled, session_completed, session_joined, session_left, session_spot_opened}, facility_id, active: true }.session_hold_created / session_hold_released) are non-durable, SSE-only — NOT delivered by webhook (they carry is_durable=false), so they are deliberately omitted from event_types[]...._provision_session_webhook_subscriptions.sql — for existing OpenRally facilities, INSERT the session_* subscription row alongside their existing booking_*/lesson_* rows (extend the backfill pattern from 20260518084747_unify_webhook_subscriptions_for_openrally.sql:100–116). Additive INSERTs only; ON CONFLICT DO NOTHING to stay idempotent and reversible. Provision booking_*/lesson_*/session_* together so no family is left orphaned.services/event_fan.rs, PgListener on or_events_channel) needs no change — session_* flows the same outbox as booking_*/lesson_*; only the subscription rows gate webhook delivery.| Step | Phase | Migrations (all additive/reversible) | Gate |
|---|---|---|---|
| 6.1 Payment-reconciliation rewrite | A | pending+held state, hold_expires_at, sweep index, pg_cron sweep | GATE-0 — NO-GO until phantom-paid rows are impossible |
| 6.2 Typology header + ext-payment cols + seat-hold RPCs | B | booking_id NULLABLE, typology+mapping FKs+one-of CHECK, ext-payment cols/CHECKs/indexes on match_players+program_enrollments, hold_session_seat/confirm_session_seat_from_hold/cancel_session_seat/release_session_hold RPCs | depends on 6.1 |
6.3 Emit rewrite + emit_program_event + neutral vocab | B | replace emit_match_event/emit_match_player_event (LEFT JOIN + session_* + neutral payload), new program triggers | frozen wire vocab + family noun signed off here |
6.4 Protocol session_* family | C | (protocol code; no producer migration) | cargo test green after each DB-touching change; version bumped |
6.5 webhook_subscriptions auto-provision + backfill | B | onboarding insert + backfill migration | no orphaned session_* events |
The paid consumer paths (§7 TPS, §8 Flutter) are blocked until 6.1 is green and 6.4 is version-bumped. Decisions DEC-A … DEC-H are logged to decisions.ts; docs synced per .claude/rules/docs-sync.md (versions.ts, feature-matrix.ts, OpenRally PRD, database.types.ts regen).
This section is consumer-side scope (per principle 2, it ships after the producer payment gate in Phase A and the producer/protocol additions in Phases B–C are green; this is Phases D and E). It does exactly one thing: it makes TPS treat a Setpoint open-match and a Setpoint program as natively as it already treats a Setpoint court booking and a Setpoint lesson — same dispatch gate, same booking_third_party_references (BTPR) bridge, same TPS-owns-the-charge payment ownership, same dual-key reverse-flow loop guard. Nothing here teaches TPS the protocol's internals: the wire stays the opaque session_* family + opaque category, and TPS routes on data.category (open-match → native Matches; clinic/league/camp → native Events). The lesson stack (openrally-lesson-webhook + create-coaching-booking-v2 + confirm-coaching-booking-v2 + OpenRallyCoachService) is copied file-for-file; the court stack (webhook-setpoint-booking-update/dispatcher.ts) is the secondary template for the inbound dispatcher; the one genuinely net-new surface is the multi-participant per-seat join/leave round-trip (DEC-G), which neither lessons (add-coaching-players, TPS-native-only) nor bookings provide.
All new edge functions obey the standing TPS rules: deno lint supabase/functions/ clean, RLS never bypassed from the client (all writes go through service-role edge functions), no payment data stored (only the opaque external Stripe PaymentIntent id is passed to Setpoint, never card data), and seat mutations use Postgres row-locking (the producer-side FOR UPDATE in hold_session_seat/confirm_session_seat_from_hold from Phase B; the TPS side relies on that lock plus the BTPR read-guard + unique index for idempotency). Both the backend and app changes ride one PostHog flag tps-{ticket}-session (5% → 25% → 50% → 100%) and require Lead + Security PR approval.
openrally-session-webhook/ (NEW)Copy openrally-lesson-webhook/{index.ts,handlers.ts} verbatim — a NEW physically-separate deployed edge function subscribed to its OWN webhook_subscriptions row for the session_* family (do NOT extend the lesson/booking switch; this mirrors how lessons did not extend webhook-setpoint-booking-update).
padel-backend/supabase/functions/openrally-session-webhook/index.ts — the HTTP shell, copied from openrally-lesson-webhook/index.ts:79-150. Parse the same envelope shape ({ id, type, created, facility_id, entity_id:"or://setpoint/session/<uuid>", data:{...} }), read eventType from envelope.type with header fallback x-openrally-event (the dual-path pg_cron-worker / legacy-Rust-dispatcher pattern at index.ts:125), and switch on the durable session_* lifecycle events. ACK unknown event types with 200 {ok:true, acknowledged:true, reason:"unknown_event_type:..."} (index.ts:140-145) so OpenRally does not retry — this absorbs the non-durable session_hold_created/session_hold_released events which are SSE-only and never webhook-delivered (Phase B/§4.6). HMAC verification stays deferred, matching the existing posture of all three inbound TPS webhooks (index.ts:72-77).padel-backend/supabase/functions/openrally-session-webhook/handlers.ts — copied from openrally-lesson-webhook/handlers.ts, with the dual-key loop guard findSessionBtpr(db, fullUri, bareUuid) modeled on findLessonBtpr (handlers.ts:109-120): .in([bareUuid, fullUri]) on booking_third_party_references.third_party_reference WHERE platform_name='setpoint', prefer the forward (full-URI) row, run FIRST in the create handler and skip with reason:'tps_owns_creation' / 'already_written' when TPS owns it (DEC-F). Handlers:handleSessionCreated / handleSessionFilled / handleSessionJoined / handleSessionLeft / handleSessionSpotOpened — route on the opaque data.category:category==="open-match" → native Matches writeback (§7.1.4 A): matches + ephemeral_teams (+ match_info if court-less), per-step compensating rollback (no supabase-js transactions, exactly the manual-rollback discipline of openrally-lesson-webhook/handlers.ts:140-444).category in clinic|league|camp|... → native Events writeback (§7.1.4 B): events + event_participants (+ event_wait_list when spots_open==0/waitlisted).data.primary_player_email and each seat's participant email → users; facility uuid → courts via clubs_3rd(platform_name='setpoint'); for programs, organizer → events.user_id. This per-seat email loop is the net-new piece vs the single-payer lesson reverse flow.handleSessionCancelled / inbound session_left (for the booker) → delegate to the shared generic handleExternalCancellation (the SAME path Playtomic/Kross/lessons use, src/services/ExternalBookingCancellationService.ts, imported in openrally-lesson-webhook/index.ts:80), with a native push titled "Match/Event Cancelled" (Setpoint label hidden), idempotent if already cancelled.config:{ booking_kind:'session', category, origin:'admin_writeback', openrally_session_id } — the booking_kind:'session' marker on config is the discriminator that carves session out of the generic court automation, exactly as booking_kind:'coach_lesson' does today.padel-backend/supabase/functions/create-session-booking-v1/index.ts (NEW) — copy create-coaching-booking-v2/index.ts. Detect usesOpenRally(club) (single gate, src/utils/openRallyHelpers.ts:14), create the TPS-local pending row, call OpenRallyMatchService.createHold (§7.1.5) → POST /openrally/v1/session-holds with X-OpenRally-Idempotency-Key: tps-session-{bookingId}-hold, then write the master BTPR status:'hold', third_party_reference = full or://setpoint/session/<uuid> URI, config:{ session_hold_id, slot_uri?, openrally_session_id, booking_kind:'session', category } + a TPS-native pending row (a coach_reservations-equivalent for the match rail, or event_participants{joined:false} for the program rail). On ANY failure run the best-effort compensating rollback that frees the BTPR + native rows + releases the hold (mirroring create-coaching-booking-v2's rollback).padel-backend/supabase/functions/confirm-session-booking-v1/index.ts (NEW) — copy confirm-coaching-booking-v2/index.ts. Find the master BTPR where config.booking_kind='session', gate on `paymentDetails.success===true` (the same gate processThirdPartyBooking enforces for courts), then call OpenRallyMatchService.confirmBooking → POST /openrally/v1/session-bookings with X-OpenRally-Idempotency-Key: tps-session-{bookingId}-book and the v1.1 payment:{ status:"paid", method_type:"prepaid", external_ref:<TPS Stripe PI id>, amount, currency } block. Flip BTPR hold→confirmed (rewriting third_party_reference to the confirmed session URI as confirmBooking does for lessons) and the native pending row → confirmed; send the native confirmation email.padel-backend/supabase/functions/get-session-availability/index.ts (NEW) — copy the get-coaching-availability-v2 shape (and the bookingRepository.createBooking OpenRally branch that bypasses UpStash). GET /openrally/v1/facilities/{facilityId}/sessions?date=&sport=&category= with X-API-Key: club.clubs_3rd.config.api_key, reading spots_total/spots_open to drive the open/full badge. Normalize via a sessions-shaped analogue of openRallySlotsToAvailability (src/utils/openRallyHelpers.ts:67).OpenRallyMatchService.cancelSeat → POST /openrally/v1/session-bookings/{uuid}/cancel with X-OpenRally-Idempotency-Key: tps-session-{bookingId}-cancel and {reason}; update BTPR status:'cancelled'. Refund stays TPS-owned (TPS made the charge).add-coaching-players BUT round-trip it (DEC-G, net-new)add-coaching-players/index.ts is TPS-native-only (fixed extra_player_id_1/2/3, hard 4-cap, no OpenRally call, no BTPR write) so today extra participants are invisible to Setpoint. The lesson precedent is therefore insufficient.
padel-backend/supabase/functions/add-session-players/index.ts (NEW) — copy add-coaching-players/index.ts's thin shell, but route through OpenRallyMatchService.holdSeat/confirmSeat for EACH added participant so each seat goes hold → pay (TPS Stripe) → confirm with its own external_ref, and writes one BTPR per seat (is_master_record:false, config.booking_kind:'session', config.participant_email). This gives the forward+reverse contract (session_joined/session_left/session_spot_opened) the round-trip that coaching_slot_details.extra_player_id_* never had. Leave/remove mirrors remove-coaching-player plus a POST /session-bookings/{seatUuid}/cancel.Setpoint sessions are routed by the opaque category onto TPS's existing native surfaces (never a new "OpenRally" surface), so downstream code never branches on origin:
matches (type:'friendly'/'competition', status:'open', is_open:true, booking_id = the OpenRally-synced booking, NULL/meta_id→match_info for court-less events) + 2× ephemeral_teams (4 fixed seats, free/split). This is the create-match/matchRepository.createMatch pattern. The matches.booking_id is the documented join point; match_players seats ↔ TPS 4-seat team slots; max_players/current_players ↔ the 4-slot render. Do NOT map an open-match onto TPS events (no fixed-seat team model).events (pre_payment:true, max_participants = program capacity, price, currency, organizer user_id) + event_participants{joined:true, tags:['paid']} + event_wait_list (when spots_open==0) + event_payment_details (the per-seat Stripe ledger). This is the communityV2 paid-join rail (event_action_buttons Pay-to-join/Waitlist). Convert units at the boundary: TPS events.price is MAJOR units (charged price*100) whereas the wire price.amount is MINOR units. Do NOT map a program onto TPS matches (no capacity/waitlist/payment ledger), and keep the wire opaque so the CEO-word "events" never collides with TPS's priced events table (which is the Program analog).padel-backend/supabase/functions/src/utils/openRallyHelpers.ts — no string change needed: usesOpenRally(club) already gates on platform_name===PlatformNames.setpoint (openRallyHelpers.ts:14). The new bookable TYPE is discriminated by config.booking_kind:'session' on the master BTPR (the same single-file dispatch precedent as 'court_booking' vs 'coach_lesson'). Add a sessions-shaped normalizer alongside openRallySlotsToAvailability here.padel-backend/supabase/functions/src/services/openRallyMatchService.ts (NEW) — copy src/services/openRallyCoachService.ts's createHold/confirmBooking/cancelLesson into OpenRallyMatchService.createHold/confirmBooking/cancelSeat/holdSeat/confirmSeat, swapping /lesson-holds→/session-holds, /lesson-bookings→/session-bookings, idempotency prefix tps-coach-→tps-session-, and resolving identity by email for every participant.padel-backend/supabase/functions/src/utils/constants.ts — add the session_* booking_kind constant; PlatformNames.setpoint is unchanged.or://setpoint/session/<uuid> URI in booking_third_party_references.third_party_reference (forward), and the bare uuid for the reverse origin:'admin_writeback' row (DEC-F). config carries { session_hold_id, slot_uri?, openrally_session_id, booking_kind:'session', category }.(platform_name, third_party_reference) on booking_third_party_references (a padel-backend/supabase/migrations/* migration) to activate the latent 23505 idempotency fallback (today only the findSessionBtpr read-guard dedups; a concurrent admin double-emit is the residual gap the index closes).session_* webhook_subscriptions row per facility at onboarding pointing at openrally-session-webhook (separate from booking_*/lesson_* rows) — without it events silently reach no consumer (club-673 failure).The app NEVER calls OpenRally directly — every call goes through the new TPS edge functions, which dispatch. Sessions surface in the existing Play feed without a new "OpenRally" tab (reuse-don't-reinvent). The feed (PlayRepository.getUpcomingEvents, _type tagging in lib/features/play/presentation/controller/main_play_controller.dart:125) must tag Setpoint sessions as match or event so main_play_screen.dart _renderUpcomingItem (:806) polymorphically renders them.
lib/shared/platform_names.dart: usesOpenRally(platformName) already returns true only for PlatformNames.setpoint (:17); sessions reuse it (single-file extension point, mirroring backend openRallyHelpers.ts). No string change.open-match sessions render via the existing IMatch/open-match rail: UpComingBooking (lib/features/play/presentation/widgets/upcoming_booking.dart), status==open → openMatch badge (:167), and matchInfo.externalPlatform already routes to ExternalMatchDetailsScreen (:439). Payment uses the server-confirm booking model (booking_detail_controller.dart handleConfirmAndPay → confirm-session-booking-v1, realtime listen on the bookings row → confirmed). Cancel reuses cancel-booking-v1 (24h gate, booking_confirmation_controller.dart:170).clinic/league/camp sessions render via the native Event paid-join+waitlist rail in lib/features/community: event_action_buttons.dart Pay-to-join/Leave/Waitlist/Request-to-join (:506), and the client-Stripe flow pay_join_event_controller.dart (:119) → confirm-pay-event-v2 → Stripe.instance.confirmPayment with the per-merchant publishableKey swap (event_repository.dart:234) — the existing per-transaction key-swap mechanism honors the TPS-owns-charge decision (DEC-C). The academy Program screen (lib/features/academy) is a dead Strapi brochure (no-op learnMore CTA, empty academy_repository.dart) and must NOT be used.add-session-players flow) surface through the existing coaching add/remove-players UI patterns, but each added seat triggers the per-seat hold→pay→confirm round-trip.external_ref.lib/features/play/presentation/widgets/upcoming_booking.dart: _getPlatformLogoWidget / _bookingNumber have no setpoint case today (only playtomic/kross/junglepadel), so a Setpoint item currently shows generic/Playtomic-default branding; add a setpoint branch (logo + reference label).deno lint supabase/functions/; no // deno-lint-ignore.matches/events/BTPR directly.FOR UPDATE lock in Phase B's hold_session_seat/confirm_session_seat_from_hold; the TPS side adds the unique (platform_name, third_party_reference) index + read-guard for idempotency.external_ref); no PAN/card data anywhere; Setpoint records method_type='prepaid' and never charges on this path.tps-{ticket}-session gates both the backend forward functions and the app rails, rolled 5%→25%→50%→100%.This is the single most consequential design axis: who charges the card vs who records that money moved, and how the two Stripe accounts (TPS real/live, Setpoint test-now/live-later) stay disjoint. The model is already proven live for court bookings and lessons — matches/programs replicate it.
| Charges the card (merchant-of-record) | Records the settlement | |
|---|---|---|
| OpenRally-originated join (TPS app user, surfaced natively) | TPS — its own real/live Stripe (create-payment-intent-v2 PromptPay / confirm-pay-event-v2 client-Stripe / confirm-pay-booking-v3 server-Stripe). For a program rendered as a TPS events row, this is the connected-account direct charge (payToJoinEvent, organizer's user_connected_accounts.access_token, +3% processing +3% application_fee_amount). | Setpoint records only — match_players.payment_method='external' + external_payment_ref (opaque, the TPS Stripe PaymentIntent id) + external_payment_method_type='prepaid'. Setpoint NEVER calls Stripe. |
| Setpoint-native join (admin/operator creates inside Setpoint app) | Setpoint — its own Stripe (TEST keys today, LIVE later) via the rewritten hold→PI→confirm flow (Phase A). | Setpoint records payment_method='stripe', payment_intent_id, reconciled by webhooks/stripe. |
The two charging paths never share a Stripe account. An OpenRally join is recorded as external/prepaid and Setpoint's keys are simply not invoked, so whether Setpoint is on test or live keys is irrelevant to that path. This is the resolution of the "real-vs-test Stripe" concern — it is structurally a non-issue on the protocol path (DEC-C).
Setpoint already exposes the recording contract: emit_booking_event (00000000000000_initial_schema.sql:1664-1671) appends a payment object to the outbound payload only when `external_payment_ref IS NOT NULL`, otherwise omits it (legacy-shape-safe):
payment = { status: <payment_status: paid|pending|partial>,
method_type: <external_payment_method_type: prepaid|pay_at_venue|comp|credit|other>,
external_ref: <opaque consumer ref, e.g. TPS Stripe PI id — never interpreted>,
amount: <minor units>,
currency }session-bookings MUST carry this same block. confirm_session_seat_from_hold(hold_id, customer, payment_status, external_ref, method_type) flips payment_method→'external' only when both `external_payment_ref` AND `external_payment_method_type` are supplied — copied verbatim from confirm_lesson_from_hold (20260515084759:97-103) and enforced by a new match_players_external_payment_consistency_check / program_enrollments_external_payment_consistency_check mirroring bookings_external_payment_consistency_check (initial_schema.sql:3180). external_ref is stored and echoed, never parsed (the existing bookings.external_payment_ref comment, :3191, is the binding rule).
Required additive columns on match_players and program_enrollments (neither has them today; the producer-side payment_method CHECK currently allows only {stripe, pay_at_club}): external_payment_ref text, external_payment_method_type text (same 5-value enum CHECK), widened payment_method CHECK to add 'external', plus the consistency CHECK and a partial index mirroring idx_bookings_external_payment_ref (:6628).
Matches/programs are multi-seat, so payment is per-seat, not per-booking — this is the genuinely new surface vs single-payer bookings/lessons (DEC-G). Each seat is its own match_players / program_enrollments row carrying its own paid_amount (minor units) + its own external_payment_ref. A four-seat split match can therefore have four independent TPS PaymentIntents, four external_ref values, and seats that confirm independently. The hold→pay→confirm lifecycle runs once per seat: hold_session_seat reserves a single seat (join_status='held'), the consumer charges that one participant, confirm_session_seat_from_hold settles that one seat and update_match_player_count advances current_players by exactly one. There is no all-or-nothing roster transaction; partially-filled paid matches are a normal state.
Units conversion is a per-seat boundary concern: Setpoint stores minor units (cost_per_player_amount, programs.price_amount); TPS events.price is major units charged as price*100. The adapter converts at the session boundary (the price{amount(minor),currency} field is canonical minor-units), and the recorded amount in the payment block is minor-units throughout. (Watch the flagged TPS render bug ExternalBookingCreationService.ts:201 when copying reverse-flow.)
Because TPS owns the charge on the OpenRally path, TPS owns the refund — Setpoint issues no refund for an OpenRally-settled seat. On cancel:
cancel-session-booking-v1 → cancel_session_seat): seat released (spots_open++); for programs promote_next_waitlisted fires (Phase B wires it); Setpoint UPDATEs the seat to cancelled. The refund decision (refund_eligible, free_cancellation_hours, hours_until_start returned by the cancel response, mirror LessonCancelResponse) is informational — TPS executes the actual Stripe refund against its own account, exactly as event leaves currently mark event_payment_details.status='refunded'.session_cancelled/session_left webhook → handleExternalCancellation): marks BTPR cancelled, runs cancel-booking-v1 as admin, native push; TPS still owns any refund.match_players/event_participants, no money moves. (Setpoint match scoring/result_* events are out of scope for the payment ledger.)Setpoint represents refund state via payment_status on the row. Note match_players has no `payment_status` column today (unlike bookings); recording a consumer-issued refund requires either an additive payment_status column on match_players/program_enrollments or treating the cancelled seat row as the refund record. Decide this in Phase B (logged as a decision — see §14).
Setpoint Stripe is on test keys today. The defect in §12 (phantom-paid rows) is *latent only* because of this. The cutover to live keys is a separate, later, independent switch and MUST NOT be coupled to shipping matches/programs:
external/prepaid and never touches Setpoint's keys, so it ships and goes to paid exposure on the OpenRally side independent of Setpoint's key state.Do not write any blueprint task, gate, or PR that says "ship matches/programs ⇒ flip Setpoint to live keys." The cutover is its own ticket with its own go/no-go, owned by the CEO, sequenced after Phase A and after the OpenRally paid path has soaked.
One feature = one PR (setpoint/CLAUDE.md workflow). PRs are sequenced by repo and gated. The payment-fix gate (Phase A) blocks every paid path — no paid OpenRally or native exposure ships until it is green. Setpoint PRs follow the 13-step workflow (clarify → AC in apps/docs/src/data/ac-<feature>.ts → build → E2E → self-review → docs-sync → merge → deploy). TPS PRs need Lead + Security approval, PostHog flag, mandatory linters.
| PR | Title | Go/no-go gate |
|---|---|---|
| A1 | feat(matches): hold→pay→confirm join lifecycle — rewrite matches/[id]/join/route.ts to insert a non-counting join_status='pending' seat + hold_expires_at, create PI, return client_secret; NEW matches/[id]/join/confirm/route.ts (copy bookings/[id]/confirm/route.ts) requiring pi.status==='succeeded' before flipping to joined. | E2E: abandoned checkout leaves no phantom-paid seat and does not advance current_players. |
| A2 | feat(payments): match_join + program_enrollment webhook reconcile — add meta.type==='match_join' and 'program_enrollment' cases to webhooks/stripe/route.ts payment_intent.succeeded (copy the credit_topup flip at :668-700, idempotent) + payment_failed/expired-hold sweep (pg_cron, mirror idx_bookings_expired_holds). | E2E: succeeded webhook with no client-confirm still settles the seat exactly once; failed/expired sweep removes the pending seat. |
| A3 | fix(programs): move enrollment settlement into webhook — keep enroll/route.ts parking paid rows at 'waitlisted'; ensure a stranded client (never calls confirm-enrollment) is reconciled by the A2 webhook, not left 'waitlisted' with money taken. | E2E: stranded paid enrollment becomes 'enrolled' via webhook; no money-taken-no-seat state. |
GATE-0: A1+A2+A3 merged, deployed, E2E green. Until then, every Phase C/D/E paid path is blocked.
| PR | Title |
|---|---|
| B1 | feat(matches): court-less events — ALTER matches.booking_id DROP NOT NULL; convert emit_match_event (:1756) + emit_match_player_event (:1819) INNER JOIN on bookings/courts to LEFT JOIN so NULL booking_id still emits (resource_id optional). |
| B2 | feat(payments): external-payment columns on seats — additive external_payment_ref + external_payment_method_type + widened payment_method CHECK + consistency CHECK + payment_status(see §8.4) + index on match_players & program_enrollments (mirror bookings). |
| B3 | feat(rpc): session seat-hold RPCs — hold_session_seat / confirm_session_seat_from_hold / cancel_session_seat (mirror hold_lesson_slot + confirm_lesson_from_hold), accepting external_payment_ref/method_type, stamping 'paid' only on confirm; add 'held' to join_status/status CHECKs. |
| B4 | feat(events): neutral-vocabulary session_* emit — translate emit payloads to FROZEN neutral vocab (spots_total/spots_open, level{min,max}, opaque category); remap orphaned match_* names → session_*; NEW emit_program_event emitting the same session_* names (programs emit nothing today). Decide the frozen wire vocabulary ONCE here (DEC-E), before any consumer subscribes. |
| B5 | feat(programs): wire promote_next_waitlisted into cancel_session_seat (currently unwired). |
| B6 | feat(onboarding): auto-provision session_* webhook_subscriptions per facility (backfill booking_*/lesson_*/session_* together) — fixes the orphaned-producer/club-673 silent drop. |
Gate B: session_* events fan out to a real subscription; cargo/E2E green; vocabulary frozen and logged.
protocol/server) — additive opaque session_* surface| PR | Title |
|---|---|
| C1 | feat(protocol): session URIs + models — uri.rs session/session_hold fns (reuse parse_uuid_uri); models/session.rs (copy lesson structs, add category/spots_total/spots_open/level/optional resource_id). |
| C2 | feat(protocol): session discovery — GET /facilities/{id}/sessions (copy availability.rs, add two capacity ints + opaque category). |
| C3 | feat(protocol): session-holds + session-bookings — copy lesson_holds.rs + lesson_bookings.rs, call B3 RPCs; reuse db::idempotency BEFORE business logic; payment-block validate-against-list-price; register routes in handlers/mod.rs. |
| C4 | chore(protocol): spec + version bump — add Session/SessionHold/SessionBooking/SessionAvailability to openapi.yaml + schemas/*.json + the session_* rows to the SSE table; bump protocol version. booking_*/lesson_* shapes FROZEN. cargo test after every DB-touching change. |
Gate C: protocol version published; existing shapes unchanged; contract review sign-off.
| PR | Title | ||
|---|---|---|---|
| D1 | feat(openrally): session webhook — NEW openrally-session-webhook (index.ts+handlers.ts, copy lesson webhook); dual-key findSessionBtpr (bare-uuid reverse + full-URI forward) running FIRST; ACK unknown types 200; route on opaque data.category → matches+ephemeral_teams (open-match) vs events+event_participants(+event_wait_list) (clinic/...); per-step compensating rollback; email-bridge identity (DEC-223) per participant. Add unique (platform_name, third_party_reference) index. | ||
| D2 | feat(openrally): forward session stack — create-session-booking-v1 (hold), confirm-session-booking-v1 (confirm), get-session-availability, NEW OpenRallyMatchService (copy OpenRallyCoachService), idempotency keys `tps-session-<id>-{hold | book | cancel}; gate OpenRally push on paymentDetails.success=true; pass TPS Stripe PI as external_ref (DEC-C); store FULL or://setpoint/session/<uuid> URI in BTPR; extend usesOpenRally only in openRallyHelpers.ts; reuse handleExternalCancellation` for inbound cancels. |
Gate D: cross-system integration tests green (§11); Lead + Security approval; PostHog flag tps-{ticket}-session created.
| PR | Title |
|---|---|
| E1 | feat(play): render Setpoint open-matches natively via IMatch/open-match rail (main_play_screen _renderUpcomingItem); backend feed _type tags session→match; close Setpoint-origin branding gap in upcoming_booking.dart (_getPlatformLogoWidget/_bookingNumber have no setpoint case). |
| E2 | feat(play): render Setpoint programs natively via the Event paid-join+waitlist rail (lib/features/community event_action_buttons.dart, confirm-pay-event-v2 client-Stripe with per-merchant publishableKey swap); _type→event. Do NOT use the academy "Program" screen (dead Strapi brochure, no-op CTA, empty repo file). |
Gate E: flutter analyze + dart format; Lead + Security; PostHog flag 5%→100% (§10). Cancel reuses cancel-booking-v1 (24h gate).
Per TPS rules (mobile-app-padel/CLAUDE.md), every consumer/app PR sits behind a PostHog flag tps-{ticket}-{slug}, ramped 5% → 25% → 50% → 100%.
| Flag | Gates | Repo |
|---|---|---|
tps-{ticket}-session-webhook | D1 reverse-flow native writeback (admin-originated Setpoint sessions appearing in TPS) | padel-backend |
tps-{ticket}-session-join | D2 forward hold/confirm/cancel (TPS users joining Setpoint sessions) | padel-backend |
tps-{ticket}-session-render | E1+E2 native rendering in the Play feed + Event rail | mobile-app-padel |
Ramp discipline: hold each step ≥24–48h with the §12 dashboards green (no phantom seats, no orphaned events, refund parity). A flag step up is itself a go/no-go: do not advance past 5% until the cross-system soak (§11) is clean at the current step. The forward-join flag (-session-join) must not reach >0% until GATE-0 (Phase A) is green in production, because that is the first paid cross-system exposure.
Matches/Programs are code-complete but nav-hidden in Setpoint admin. Un-hiding is its own sequenced reveal, decoupled from the TPS flags and gated on Phase A:
'waitlisted' (only the webhook net is missing, fixed in A2/A3), so it reaches payment-safety with the least surface.Sequencing rule: never un-hide a paid surface in Setpoint admin before its Phase A fix is deployed. A parked surface staying parked is the safe default; record *why* each un-hide is unblocked (ties to §12 "parked-without-recorded-why").
Route a change through staging (not straight to a prod flag ramp) when either:
external_payment columns/RPCs (B2/B3), the session-bookings payment block (C3), or the TPS forward-join settlement (D2). Money paths get a full staging cycle before any prod %.session_* event family round-trips Setpoint→OpenRally→TPS (or reverse) for real: D1's first admin-writeback and D2's first forward hold→confirm each get a staging dry-run against a staging webhook subscription before the 5% prod step..claude/rules/e2e-testing.md)match_players row with join_status='joined', current_players unchanged, match not shown "full." Repeat for program enroll (no 'enrolled'/money-taken-no-seat).join_status='held') reserves capacity (double-sell blocked) but does not advance current_players; confirm flips to joined and advances by exactly one; expired hold is swept and frees the seat.payment_intent.succeeded with meta.type match_join/program_enrollment settles idempotently (replay = no double-settle); payment_failed cancels the pending seat.booking_id; assert session_* event still emits (LEFT JOIN) with resource_id absent.external_payment_ref+method_type → payment_method='external', consistency CHECK passes; without both → CHECK rejects.openrally-lesson-webhook/booking-webhook test shape for openrally-session-webhook: dual-key loop guard (findSessionBtpr matches bare-uuid AND full-URI; forward-owned → ACK 'tps_owns_creation'); unknown event type → 200 ACK; category routing (open-match→matches, clinic→events); per-step compensating rollback on a mid-write failure; 23505 idempotency on the new unique index.OpenRallyMatchService: idempotency keys tps-session-<id>-{hold|book|cancel}; push gated on paymentDetails.success=true; external_ref passed through to confirm.session-tagged item → UpComingBooking (open-match badge) for match, UpcomingEventItem for event; Setpoint-origin branding renders (no Playtomic-default fallback).publishableKey swap restores PROD_PUBLISHABLE_KEY in finally; cancel honors 24h gate.Mirror the existing OpenRally end-to-end integration cases (the booking/lesson round-trip suites are the template):
session-bookings confirm with external_ref → Setpoint seat joined/external/prepaid → session_joined webhook → TPS BTPR confirmed.session_created/session_joined → TPS native matches+ephemeral_teams or events+event_participants → loop guard prevents re-push.current_players advances per-seat; one seat's failure does not block the others (DEC-G).event_wait_list; a cancel fires promote_next_waitlisted + session_spot_opened → TPS promotion.This is asserted end-to-end, not just unit: (a) Setpoint native abandon (§11.1); (b) TPS forward join where the TPS charge fails *after* a Setpoint hold → assert the compensating rollback releases the hold (spots_open++) and no BTPR/native row lingers; (c) expired-hold sweep on both sides frees capacity. A held-but-never-paid seat must never count toward current_players or show a session "full."
| # | Risk | Sev | Mitigation |
|---|---|---|---|
| R1 | Phantom-paid seat — matches/[id]/join/route.ts sets join_status:'joined',payment_method:'stripe',paid_amount immediately after paymentIntents.create() (before card confirm), and webhooks/stripe/route.ts has no match_join/program_enrollment case → abandoned card = permanent paid seat that increments current_players and shows "full" with unpaid seats. | C | Phase A (DEC-B) hard gate: hold→pay→confirm rewrite + webhook reconcile + expired-hold sweep, before any paid path. Latent today only because Setpoint = test keys — do not rely on that. |
| R2 | Webhook-subscription onboarding gap — match_* emitted into or_events but no webhook_subscriptions row lists them; programs emit nothing. fan_out_or_event silently drops (club-673 mode) → consumer never sees the session. | H | B6: auto-provision session_* (with booking_*/lesson_*) per facility at onboarding + backfill existing facilities. Integration test asserts a real delivery row is created. |
| R3 | Native-equivalence leak — TPS code branches on Setpoint origin instead of rendering natively; or the protocol learns "match"/"program"/"event". Breaks the consumer-agnostic invariant and couples the wire to Setpoint internals. | H | One opaque session_* family + opaque category (DEC-A); TPS routes on category to existing native matches/events; protocol never branches. Frozen neutral vocab decided once in B4 (DEC-E). |
| R4 | Real-vs-test Stripe collision — a Setpoint test-key charge enters a real-money path, or merchant-of-record is ambiguous between TPS-live and Setpoint-test. | H | DEC-C: TPS charges (real), Setpoint records external/prepaid only; the two accounts never meet on the OpenRally path. Live-keys cutover is a separate ticket, decoupled (§8.5). |
| R5 | Parked-without-recorded-why — a surface stays nav-hidden (or gets un-hidden) with no logged rationale, so a future operator re-hides/exposes it blindly or assumes it's broken. | M | §10.2 sequencing + log each un-hide reason as a decision (DEC, §14). Default safe state = parked; un-hide only when its Phase A fix is deployed. |
| R6 | Per-seat payment partial-failure — a 4-seat split where some seats settle and some don't, leaving an inconsistent roster / orphaned holds. | M | Per-seat independent lifecycle (no all-or-nothing); compensating rollback releases the failed seat's hold; expired-hold sweep cleans stragglers; §11.4/§11.5 cover it. |
| R7 | Loop / double-book on the TPS→Setpoint→TPS round-trip if dedup is single-key. | M | Dual-key findSessionBtpr (bare-uuid + full-URI) running FIRST (DEC-F) + new unique (platform_name, third_party_reference) index to activate the 23505 fallback (latent today). |
| R8 | Refund representation gap — match_players has no payment_status column (unlike bookings), so a TPS-issued refund has nowhere clean to record on Setpoint. | M | B2 adds payment_status to match_players/program_enrollments (or treat cancelled row as refund record — decide & log, §14). TPS owns the actual refund. |
| R9 | Units mismatch — Setpoint minor-units vs TPS events.price major-units (*100); plus flagged TPS 100× render concern (ExternalBookingCreationService.ts:201). | M | Convert at the session boundary (minor-units canonical in price.amount); add a boundary unit test; verify the flagged render bug when copying reverse-flow. |
| R10 | `booking_id` NULL breaks emit — emit_match_event/emit_match_player_event INNER JOIN bookings/courts; NULL booking_id drops the row and emits nothing. | M | B1: DROP NOT NULL + convert to LEFT JOIN; E2E asserts court-less emit (§11.1). |
| R11 | Frozen-too-early / wrong wire noun — vocab or the session noun ships, a consumer subscribes, then it's a frozen contract that's wrong. | L | Decide vocab + noun ONCE in B4 before any subscription (DEC-E); session chosen over event (collides with or_events) — confirm with CEO/lead (§13). |
| R12 | HMAC deferred on inbound TPS webhooks — no signature verification today. | L | Inherit the existing deferred posture; the new session webhook adopts org-wide HMAC when the booking/lesson webhooks do (not a blocker, but tracked). |
session for the opaque family (event collides with the or_events outbox table; session reads as "group session"). Confirm session vs an alternative — this is frozen the moment the first consumer subscribes.booking_type='match' = the Matches feature, which maps to TPS native `matches` (open-match rail). TPS *also* has a priced events table — which is the Program analog. Confirm we keep the wire opaque so this collision never leaks, and that programs render in TPS as events (not academy).session_* subscriptions auto-provisioned for the soak?apps/docs/src/data/decisions.ts — DEC-xxx)Append to apps/docs/src/data/decisions.ts (schema { id, title, context, decision, alternatives[], rationale }; current max is DEC-258, so these start at DEC-259). Each carries the rejected alternatives per the self-debate rule (setpoint/CLAUDE.md).
| New ID | Title | Decision (1-line) | Rejected alternatives |
|---|---|---|---|
| DEC-259 | One opaque session_* protocol family + category | Mint ONE capacity-bearing session_* family carrying an opaque category passthrough; protocol never learns "match"/"program"/"event"/"clinic". | Two parallel match_*/program_* families (leaks typology, doubles webhook/onboarding surface, breaks native-equivalence). |
| DEC-260 | Wire noun = session | Use session as the wire word; Event/Program live only in Setpoint admin header. | event (collides with or_events); group_booking; match (typology leak). |
| DEC-261 | Payment-fix is a hard GATE-0 before any paid path | Rewrite match-join (and program webhook net) to hold→pay→confirm + webhook reconcile before any paid OpenRally/native exposure. | Ship paid path on current flow, fix later (produces phantom-paid rows; banned log-and-continue). |
| DEC-262 | TPS charges, Setpoint records (consumer-settled) | OpenRally joins record payment_method='external'+opaque external_ref+method_type='prepaid'; Setpoint keys invoked only for native joins. | Setpoint charges via its own (test) Stripe on the protocol path; TPS delegates charging to Setpoint. |
| DEC-263 | Live-keys cutover decoupled from this launch | Setpoint test→live Stripe switch is a separate CEO-owned ticket, sequenced after Phase A; matches/programs launch does not depend on it. | Couple key cutover to the matches/programs go-live. |
| DEC-264 | matches.booking_id NULLABLE + LEFT JOIN emits | DROP NOT NULL; convert emit-trigger joins to LEFT JOIN so court-less events still emit (resource_id optional). | Require every match to anchor a court (blocks court-less events). |
| DEC-265 | Frozen neutral capacity/level vocabulary | Translate at the emit/adapter boundary to spots_total/spots_open, level{min,max}, opaque category; finalize before first subscription. | Ship raw Setpoint column names (max_players/current_players/min_level/max_level/match_type) — frozen-wrong on first consume. |
| DEC-266 | Dual-key BTPR loop guard + full-URI reference | findSessionBtpr matches bare-uuid (reverse) AND full-URI (forward), runs FIRST; store full or://setpoint/session/<uuid> URI; add unique (platform_name, third_party_reference) index. | Single-key dedup (double-books the round-trip). |
| DEC-267 | Multi-participant per-seat round-trip (lesson precedent insufficient) | Real per-seat rows + forward/reverse session_joined/session_left/session_spot_opened so every seat propagates both ways. | Reuse coaching's fixed extra_player_id_1/2/3 (TPS-native-only, never round-trips, 4-cap). |
| DEC-268 | Seat-hold on the seat row + idempotency on mutating routes | Hold lives on match_players.join_status='held'/program_enrollments.status='held' + hold_expires_at + pg_cron sweep; reuse db::idempotency on every mutating session-* route. | Protocol-level or_session_holds table (court-less events have no or_holds analog; lessons proved row-state sufficient). |
| DEC-269 | Refund representation on seats | Add payment_status to match_players/program_enrollments (or treat cancelled row as the refund record — pick in B2); TPS owns the actual refund. | Leave seats with no refund-state column (no clean place to record consumer-issued refunds). |
| DEC-270 | Parked-nav un-hide sequencing & rationale logged | Programs-first, Matches-second, internal/pilot before GA; un-hide only after the surface's Phase A fix is deployed; log each un-hide reason. | Un-hide both at once / un-hide before payment fix / un-hide with no recorded why. |
Verified source anchors for these decisions: defect at apps/admin/src/app/api/v1/matches/[id]/join/route.ts (immediate join_status:'joined'/payment_method:'stripe' after paymentIntents.create()); missing webhook case in apps/admin/src/app/api/v1/webhooks/stripe/route.ts (switch on meta.type has booking_payment/lesson_payment/credit_topup/lesson_package/membership_subscription, none for match_join/program_enrollment); program enroll parks paid rows at "waitlisted" with metadata.type:"program_enrollment" (apps/admin/src/app/api/v1/programs/[id]/enroll/route.ts:85,164); the external-payment recording contract at 00000000000000_initial_schema.sql:1664-1671 (emit_booking_event payment block), :3177-3195 (bookings.external_payment_ref/external_payment_method_type + bookings_external_payment_consistency_check), and :6628 (partial index); decisions file at apps/docs/src/data/decisions.ts (current max DEC-258).