Developer handoff · full technical plan

OpenRally ⇄ TPS — Wiring Setpoint Matches & Programs to The Padel Society

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.

⚡ TL;DR-over-the-TL;DR

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:

  1. 1Revive Matches + Programs in the Setpoint admin — un-hide them and finish the gaps. ("Matches" *is* "Events": same feature, just the display label — there is no separate Events entity.)
  2. 2Make the data/schema changes — additive only (typology header, nullable court anchor, mapping FKs, seat-hold + external-payment columns, program events). A targeted revive, not a rebuild.
  3. 3Extend OpenRally and wire it to TPS — add one opaque 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:

  • It's "parked + one unfinished piece," not "outdated." The schema and most code are verified *current*; the only genuinely unfinished part is the join → payment reconciliation.
  • That payment fix is the hard gate — Phase A, no-go until green — and must land *before* any paid path is exposed, because TPS charges real Stripe while Setpoint still runs test keys.
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)

0. TL;DR

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.

1. Key decisions at a glance

  • Events == Matches. "Events" is just the Setpoint display label for booking_type='match'; there is no separate Setpoint Events entity to build.
  • One opaque wire family `session_*`, not match_* + program_* — keeps the protocol consumer-agnostic; TPS routes on the opaque category string.
  • Native-equivalence: Setpoint open-match → TPS native matches; Setpoint program → TPS native events + event_participants. Downstream code never branches on origin.
  • Cross-boundary payment: TPS owns the charge (real Stripe); Setpoint records a consumer-settled payment via the OpenRally v1.1 Payment block (external_ref). Do not couple the Setpoint live-keys cutover to this feature.
  • Additive-only producer migrations; lesson_*/booking_* wire shapes frozen; explicit PARKED go-ahead + DEC entries required.

Phases at a glance

PhaseTitleReposGoal
AGATE-0 (NO-GO until green): Setpoint producer payment-reconciliation fix for matches & programssetpointEliminate 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.
BSetpoint producer: additive capacity/seat-hold/external-payment schema, neutral-vocabulary emit triggers, court-less events, waitlist wiring, webhook auto-provisionsetpointMake 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).
COpenRally protocol: additive opaque session_* family (discovery + hold + book + cancel + events), modeled file-for-file on the lesson precedent, version-bumpedsetpointAdd 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.
DTPS consumer (padel-backend): inbound session_* webhook + forward hold/confirm/cancel edge functions + OpenRallyMatchService, dispatched via usesOpenRallypadel-backendMirror 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>-{holdbookcancel}. 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.
ETPS Flutter app (mobile-app-padel): native rendering of Setpoint matches (open-match rail) & programs (Event paid-join rail), with Setpoint-origin brandingmobile-app-padelSurface 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 matchevent 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'.

2. Architecture & data flow

2.1 The producer -> protocol -> consumer pattern (already live for bookings & lessons)

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:

  • Setpoint/OpenRally never reference TPS. No tps_*. The wire carries one opaque typology string (category) the protocol stores and echoes but never branches on.
  • Reuse-don't-reinvent. Each new endpoint is a copy of the lesson handler pair; each consumer edge fn is a copy of create-coaching-booking-v2/openrally-lesson-webhook; each native render uses existing TPS Events/Matches tables.
  • Native-equivalence. A Setpoint match renders as a TPS native match/open-match; a Setpoint program renders as a TPS native events row + event_participants. Downstream code never branches on origin.
  • Additive-only on live prod. Every producer migration is 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.

2.2 The ONE opaque bookable family decision (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.

2.3 Capacity & seat-hold data flow (the missing producer primitive)

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)

3. Concept mapping (Setpoint header <-> OpenRally wire <-> TPS table)

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_playerssession, 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_enrollmentssession, category="clinic"/"league"/"camp" (opaque, from program_type)TPS events + event_participants + event_wait_list + event_payment_detailsTPS events (priced, capacity, waitlist) is the Program analog — NOT TPS matches
matches.max_players / current_playersspots_total / spots_open (computed)events.max_participants / roster count; or matches 4-seat rendercapacity already native both sides
match_players row (per-seat) / program_enrollments rowone seat in session (join/leave)event_participants row / team slot player_one_idper-seat payment lives here
programs waitlist (program_enrollments.status='waitlisted', waitlist_position, promote_next_waitlisted)spots_open==0 -> waitlist seat; session_spot_opened eventevent_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 blockevents.price(MAJOR units *100) / splitunits mismatch TPS major vs Setpoint minor — convert at boundary
per-seat payment via payment_method='external' + external_payment_refOpenRally 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_typeprotocol 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.

4. End-to-end flows

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.

4.1 DISCOVER (both matches & programs)

 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.

4.2 JOIN seat (HOLD) — matches & programs identical

 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.

4.3 PAY + CONFIRM (cross-boundary payment ownership)

 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.

4.4 CONFIRM (Setpoint admin-originated) -> reverse-flow native writeback into TPS

 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).

4.5 CANCEL (both directions)

 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.

4.6 WEBHOOK fan-out & onboarding (the orphaned-producer fix)

 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.)

5. Cross-cutting architectural decisions (with rejected alternatives)

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.

6. Setpoint producer + OpenRally protocol changes

Scope of this section. Everything below is producer-side (Setpoint apps/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 in setpoint/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 destructive ALTER/DROP on live data; widen CHECKs 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 with supabase migration new <desc> (never hand-roll the timestamp). Deploy is the single approved path: supabase db query --linked --file supabase/migrations/<file>.sql on NEW files only. No db push / db reset --linked. DEC logging. Every non-trivial decision below (DEC-A … DEC-H from §5) is logged to apps/docs/src/data/decisions.ts (context, decision, rejected alternatives, rationale) as part of the chore PR that opens this work, per setpoint/CLAUDE.md → "Engineering Mindset". Wire-vocabulary and the session_* family noun (DEC-A, DEC-E) are decided once, here, before any consumer subscribes — they become a frozen contract the moment TPS subscribes.

6.1 BLOCKING PREREQUISITE — payment-reconciliation rewrite (GATE-0, DEC-B)

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:

  1. 1Additive schema for the pending-seat state — migration ..._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).
  • Partial index 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.
  • The 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).
  1. 2Rewrite `matches/[id]/join/route.ts` to hold → pay → confirm (copy the bookings/[id]/confirm/route.ts lifecycle):
  • Insert 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).
  1. 3New route `matches/[id]/join/confirm/route.ts` (copy 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).
  1. 4Webhook reconciliation cases in `webhooks/stripe/route.ts` (copy the 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.
  1. 5Expired-hold sweep — codified as a 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.
  1. 6Programs: keep the existing 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.

6.2 Event typology header over 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.)
  • Mapping FKs + one-of CHECK: 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 triggers must tolerate NULL `booking_id`. 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).
  • Widen match_players.payment_method CHECK from {stripe, pay_at_club} (:3861) to the superset {stripe, pay_at_club, external} (additive value).
  • Add a consistency CHECK on each table mirroring bookings_external_payment_consistency_check (:3180): payment_method = 'external' requires both external_payment_ref and external_payment_method_type non-null.
  • Partial index ... (external_payment_ref) WHERE external_payment_ref IS NOT NULL on each (mirror idx_bookings_external_payment_ref at :6628).
  • Note on refunds (residual): 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).
  • This requires 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).

6.3 Program emit-to-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:

  1. 1Rewrite `emit_match_event` / `emit_match_player_event` to:
  • Emit the `session_*` family names: session_created, session_filled, session_cancelled, session_completed, session_joined, session_left, session_spot_opened, plus non-durable session_hold_created / session_hold_released.
  • Emit a FROZEN neutral vocabulary payload — decided once here (DEC-E): 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.
  • Convert the bookings/courts joins to LEFT JOIN (§6.2.1) so a NULL booking_id still emits with resource_id optional.
  1. 2New `emit_program_event` trigger on 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.
  1. 3Opacity guarantee (DEC-A): the wire word is `session`; the typology header words (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).

6.4 OpenRally protocol — additive opaque 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):

  1. 1`src/uri.rs` — add 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.
  1. 2`src/models/session.rs` — copy the lesson structs from 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).
  1. 3`src/handlers/session_holds.rs` (copy 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.
  1. 4`src/handlers/session_bookings.rs` (copy 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.
  1. 5Discovery route 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).
  1. 6`src/handlers/mod.rs` — register the new routes (copy the lesson router block).
  1. 7Idempotency (DEC-H): reuse 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.
  1. 8Spec + version bump: add 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.

6.5 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:

  1. 1Onboarding auto-provision — at club/facility onboarding, auto-insert 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 }.
  • Hold events (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[].
  1. 2Backfill migration ..._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.
  1. 3The protocol's SSE fan (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.

6.6 Producer / protocol work sequencing & exit gates

StepPhaseMigrations (all additive/reversible)Gate
6.1 Payment-reconciliation rewriteApending+held state, hold_expires_at, sweep index, pg_cron sweepGATE-0 — NO-GO until phantom-paid rows are impossible
6.2 Typology header + ext-payment cols + seat-hold RPCsBbooking_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 RPCsdepends on 6.1
6.3 Emit rewrite + emit_program_event + neutral vocabBreplace emit_match_event/emit_match_player_event (LEFT JOIN + session_* + neutral payload), new program triggersfrozen wire vocab + family noun signed off here
6.4 Protocol session_* familyC(protocol code; no producer migration)cargo test green after each DB-touching change; version bumped
6.5 webhook_subscriptions auto-provision + backfillBonboarding insert + backfill migrationno 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).

7. TPS consumer changes (padel-backend + mobile app)

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.

7.1 New / extended edge functions (padel-backend)

7.1.1 Inbound reverse-flow webhook — 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).
  • Identity for every participant resolved by email (DEC-223): 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.
  • Insert the reverse BTPR keyed by the bare uuid, 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.

7.1.2 Forward-flow hold / confirm / cancel — copy the coaching pair

  • 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).
  • Cancel: reuse the existing outbound cancel path — 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).

7.1.3 Multi-participant join — copy 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.

7.1.4 Native-equivalence mapping (which TPS tables/screens)

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:

  • A) `category="open-match"` → TPS native Matches. Write 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).
  • B) `category` in `clinic|league|camp|...` → TPS native Events. Write 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).

7.1.5 Dispatch + service additions

  • 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.

7.1.6 BTPR storage + idempotency

  • Store the FULL 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 }.
  • Add the unique index (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).
  • Auto-provision the 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).

7.2 Flutter app changes (mobile-app-padel)

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.

  • Protocol dispatch — 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.
  • `booking/` feature (open-match rail) — Setpoint 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).
  • `community/` feature (program paid-join rail) — Setpoint 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.
  • `academy/`/`coaching/` — multi-participant joins (the 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.
  • In-app payment flow — reuse the two existing models per rail (server-confirm for the booking/match rail; client-Stripe sheet for the event/program rail), both honoring TPS owns the charge (real TPS Stripe; Setpoint test keys never invoked on this path) and no card data stored — only the resulting PaymentIntent id flows to external_ref.
  • Branding gap — close the Setpoint-origin branding in 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).

7.3 TPS-rule compliance checklist

  • deno lint: every new edge function passes deno lint supabase/functions/; no // deno-lint-ignore.
  • RLS: all reverse-flow and forward-flow writes happen in service-role edge functions; the client never writes matches/events/BTPR directly.
  • Row-locking: seat reservation relies on the producer 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.
  • No payment data stored: TPS stores only the opaque Stripe PaymentIntent id (external_ref); no PAN/card data anywhere; Setpoint records method_type='prepaid' and never charges on this path.
  • Feature flag: tps-{ticket}-session gates both the backend forward functions and the app rails, rolled 5%→25%→50%→100%.
  • flutter analyze + dart format clean on the app changes; Lead + Security approval on every PR.
  • Phasing: this whole section is blocked until the Phase A producer payment-reconciliation gate (no phantom-paid rows) is green; the app phase (E) ships LAST, consuming the public protocol.

8. Cross-boundary payment model

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.

8.1 The split: TPS charges, Setpoint records (the OpenRally path)

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).

8.2 The OpenRally v1.1 Payment block — the recording contract

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).

8.3 Per-participant join payment

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.)

8.4 Refund / cancel / no-show

Because TPS owns the charge on the OpenRally path, TPS owns the refund — Setpoint issues no refund for an OpenRally-settled seat. On cancel:

  • TPS-initiated (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'.
  • Setpoint-initiated (session_cancelled/session_left webhook → handleExternalCancellation): marks BTPR cancelled, runs cancel-booking-v1 as admin, native push; TPS still owns any refund.
  • No-show: not a payment-reversal event — it is a roster/result annotation on 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).

8.5 Test-keys-now / live-keys-later — explicitly decoupled

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:

  • The payment-safety rewrite (Phase A / DEC-B) is the gate, not the key cutover. Phase A must land regardless of key state — it is correct on test keys and correct on live keys.
  • The OpenRally path (§8.1) records 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.
  • Only the Setpoint-native paid join (operator pays inside the Setpoint admin app) depends on live keys. That path can remain test-only until the org separately decides to flip — and that flip is gated by Phase A being green, nothing in this blueprint.

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.

9. Phased delivery & PR plan

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.

Phase A — setpoint — GATE-0 (NO-GO until green)

PRTitleGo/no-go gate
A1feat(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.
A2feat(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.
A3fix(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.

Phase B — setpoint — producer protocol-readiness (additive-only on live prod)

PRTitle
B1feat(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).
B2feat(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).
B3feat(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.
B4feat(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.
B5feat(programs): wire promote_next_waitlisted into cancel_session_seat (currently unwired).
B6feat(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.

Phase C — setpoint (protocol/server) — additive opaque session_* surface

PRTitle
C1feat(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).
C2feat(protocol): session discovery — GET /facilities/{id}/sessions (copy availability.rs, add two capacity ints + opaque category).
C3feat(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.
C4chore(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.

Phase D — padel-backend (TPS consumer) — blocked on GATE-0 + Gate C

PRTitle
D1feat(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.
D2feat(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>-{holdbookcancel}; 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.

Phase E — mobile-app-padel (TPS Flutter) — ships LAST, consumes public protocol

PRTitle
E1feat(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).
E2feat(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).

10. Feature-flag rollout

10.1 PostHog flags (TPS side)

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%.

FlagGatesRepo
tps-{ticket}-session-webhookD1 reverse-flow native writeback (admin-originated Setpoint sessions appearing in TPS)padel-backend
tps-{ticket}-session-joinD2 forward hold/confirm/cancel (TPS users joining Setpoint sessions)padel-backend
tps-{ticket}-session-renderE1+E2 native rendering in the Play feed + Event railmobile-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.

10.2 Setpoint parked-nav un-hide sequencing

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:

  1. 1Un-hide Programs first — its enroll path already parks paid rows at 'waitlisted' (only the webhook net is missing, fixed in A2/A3), so it reaches payment-safety with the least surface.
  2. 2Un-hide Matches second — only after A1 (hold→confirm rewrite) is deployed and E2E-green, since the match-join route is the one with the live phantom-paid defect.
  3. 3Reveal to internal/pilot operators before general availability; the nav un-hide is a Setpoint-admin change (not a PostHog flag — PostHog is TPS-only).

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").

10.3 Staging triggers — when a change is forced through staging first

Route a change through staging (not straight to a prod flag ramp) when either:

  • Payment changes — anything touching the charge/record/refund path: Phase A rewrites, the 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 %.
  • First-time cross-system — the *first* time a given 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.

11. Testing strategy

11.1 Setpoint — Playwright E2E (apps/admin, per .claude/rules/e2e-testing.md)

  • Phantom-paid regression (the load-bearing test): create match → join with split payment → abandon the card → assert no match_players row with join_status='joined', current_players unchanged, match not shown "full." Repeat for program enroll (no 'enrolled'/money-taken-no-seat).
  • Hold lifecycle: hold 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.
  • Webhook reconcile: payment_intent.succeeded with meta.type match_join/program_enrollment settles idempotently (replay = no double-settle); payment_failed cancels the pending seat.
  • Court-less event: create a match with NULL booking_id; assert session_* event still emits (LEFT JOIN) with resource_id absent.
  • External-payment recording: confirm with external_payment_ref+method_type → payment_method='external', consistency CHECK passes; without both → CHECK rejects.

11.2 padel-backend — Deno tests (mirror existing OpenRally tests)

  • Copy the 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.
  • Forward OpenRallyMatchService: idempotency keys tps-session-<id>-{hold|book|cancel}; push gated on paymentDetails.success=true; external_ref passed through to confirm.

11.3 mobile-app-padel — widget/integration

  • Play feed polymorphic render: session-tagged item → UpComingBooking (open-match badge) for match, UpcomingEventItem for event; Setpoint-origin branding renders (no Playtomic-default fallback).
  • Event paid-join sheet: per-merchant publishableKey swap restores PROD_PUBLISHABLE_KEY in finally; cancel honors 24h gate.

11.4 Cross-system integration cases (the new surface)

Mirror the existing OpenRally end-to-end integration cases (the booking/lesson round-trip suites are the template):

  • Forward join round-trip: TPS app join → TPS charges → session-bookings confirm with external_ref → Setpoint seat joined/external/prepaid → session_joined webhook → TPS BTPR confirmed.
  • Reverse writeback: Setpoint admin fills a match/program → session_created/session_joined → TPS native matches+ephemeral_teams or events+event_participants → loop guard prevents re-push.
  • Per-seat multi-participant: four independent seats hold/pay/confirm independently; current_players advances per-seat; one seat's failure does not block the others (DEC-G).
  • Waitlist: program full → TPS join lands on event_wait_list; a cancel fires promote_next_waitlisted + session_spot_opened → TPS promotion.
  • Cancel/refund parity: both-direction cancel; TPS owns the refund; Setpoint records cancelled/refunded.

11.5 Abandoned-checkout / no-phantom-seat (explicit, every layer)

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."

12. Risks & mitigations

#RiskSevMitigation
R1Phantom-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.CPhase 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.
R2Webhook-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.HB6: auto-provision session_* (with booking_*/lesson_*) per facility at onboarding + backfill existing facilities. Integration test asserts a real delivery row is created.
R3Native-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.HOne 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).
R4Real-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.HDEC-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).
R5Parked-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.
R6Per-seat payment partial-failure — a 4-seat split where some seats settle and some don't, leaving an inconsistent roster / orphaned holds.MPer-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.
R7Loop / double-book on the TPS→Setpoint→TPS round-trip if dedup is single-key.MDual-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).
R8Refund representation gap — match_players has no payment_status column (unlike bookings), so a TPS-issued refund has nowhere clean to record on Setpoint.MB2 adds payment_status to match_players/program_enrollments (or treat cancelled row as refund record — decide & log, §14). TPS owns the actual refund.
R9Units mismatch — Setpoint minor-units vs TPS events.price major-units (*100); plus flagged TPS 100× render concern (ExternalBookingCreationService.ts:201).MConvert 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.MB1: DROP NOT NULL + convert to LEFT JOIN; E2E asserts court-less emit (§11.1).
R11Frozen-too-early / wrong wire noun — vocab or the session noun ships, a consumer subscribes, then it's a frozen contract that's wrong.LDecide vocab + noun ONCE in B4 before any subscription (DEC-E); session chosen over event (collides with or_events) — confirm with CEO/lead (§13).
R12HMAC deferred on inbound TPS webhooks — no signature verification today.LInherit the existing deferred posture; the new session webhook adopts org-wide HMAC when the booking/lesson webhooks do (not a blocker, but tracked).

13. Open questions for the CEO

  1. 1Wire noun sign-off. We recommend 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.
  2. 2"Events" naming collision. The CEO's "Events" = Setpoint 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).
  3. 3Un-hide order & audience. Confirm Programs-first, Matches-second, internal/pilot before GA (§10.2), and who the pilot operators are.
  4. 4Live-keys cutover ownership. Confirm the Setpoint test→live Stripe switch is a separate CEO-owned ticket, sequenced after Phase A, and not bundled with the matches/programs launch (§8.5).
  5. 5Refund authority. Confirm TPS owns refunds for OpenRally-settled seats (it made the charge) and Setpoint only records cancelled/refunded — no Setpoint-side refund on that path (§8.4).
  6. 6Waitlist on matches. Matches have no waitlist (only programs do). Confirm matches stay waitlist-less; if matches need a waitlist, that's net-new scope on both sides.
  7. 7No-show & discounts on joins. Confirm no-show is a roster/result annotation (no money movement) for v1, and that join-time discounts are out of scope for v1 (matches/programs do not use the legacy discount engine — discounts on joins would be net-new).
  8. 8Pilot facility. Which facility/club is the first to get session_* subscriptions auto-provisioned for the soak?

14. Decisions to log (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 IDTitleDecision (1-line)Rejected alternatives
DEC-259One opaque session_* protocol family + categoryMint 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-260Wire noun = sessionUse session as the wire word; Event/Program live only in Setpoint admin header.event (collides with or_events); group_booking; match (typology leak).
DEC-261Payment-fix is a hard GATE-0 before any paid pathRewrite 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-262TPS 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-263Live-keys cutover decoupled from this launchSetpoint 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-264matches.booking_id NULLABLE + LEFT JOIN emitsDROP 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-265Frozen neutral capacity/level vocabularyTranslate 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-266Dual-key BTPR loop guard + full-URI referencefindSessionBtpr 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-267Multi-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-268Seat-hold on the seat row + idempotency on mutating routesHold 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-269Refund representation on seatsAdd 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-270Parked-nav un-hide sequencing & rationale loggedPrograms-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).