Skip to content

Foundational Schema Audit — Tennis Standards & Future-Proofing

Status: Reference — Planning artifact, NOT a migration batch. 8 of 8 domains have verified, adversarially-reviewed research.

Purpose

This is a forward-looking foundational-schema audit that grounds TwoMore's data model in international tennis standards (ITF Rules of Tennis, UTR / ITF-WTN / NTRP rating bodies, ISO-3166 geography, iCalendar/schema.org event vocabularies) and future-proofs it for international expansion — without committing to a migration today. Its job is to drive careful, per-schema planning: for each domain it separates the minimal flat columns we can ship now (serving concrete near-term Korean-market features) from the fully-designed-but-held generalized model that earns its complexity only when a triggering feature (Phase 11 inter-club leagues/seasons, court-booking SaaS, international onboarding, or a real para-tennis product) actually lands. Every recommendation is grounded in running code (file:line) and a cited standard, and every domain's finding has been corrected against an adversarial verdict — where the verdict and the original finding conflict, the verdict's accuracy correction wins. Nothing here should be applied as a batch. Each "ship-now" item is a small, independent migration to schedule against its motivating feature; each "held" model is a docs/specifications/ target, not a pending SQL file.

All 8 domains are present with verified research: Identity & Affiliations · Ratings & Rankings · Skill Tiers & Divisions · Match Formats & Scoring · Tournaments & Competition Structures · Venues & Courts · Player Categories · Bespoke-domains consistency (sessions/RSVP/attendance/trust/dues).


Guiding principles

  1. Flat-now, generalized-held. The codebase's proven pattern (see CLAUDE.md): ship the minimal nullable columns that unblock a concrete near-term feature; fully design the normalized/generalized model but hold it in docs/ until a real consumer exists. Building the general model before its first consumer is the over-engineering trap this audit repeatedly flags.
  2. Standards-anchored. Geography → ISO-3166 (alpha-3 country, 3166-2 subdivision). Ratings → UTR (1.00–16.50), ITF WTN (40→1 inverted), USTA NTRP (1.0–7.0) — no official 1:1 conversion exists between any pair, so cross-maps are display-only approximations, never matchmaking inputs. Match rules → ITF Rules of Tennis (sets, tiebreaks, no-ad). Surfaces/pace → ITF surface taxonomy + Court Pace Rating. Events/RSVP → iCalendar RFC 5545 PARTSTAT + schema.org EventStatusType/RsvpResponseType.
  3. Don't conflate diverging concepts. Several current bugs are one concept modeled as another: indoor as a surface (it's an environment dimension); MatchFormat=singles|doubles (team composition) overloaded with 1_set|2_of_3 (match length); a confirmedBy field that holds a method enum not a principal id; mixed_doubles (a pairing constraint / event format) vs gender (a player attribute). Keep orthogonal axes in orthogonal columns.
  4. No dead schema, no lying UI. A nullable column nobody can populate or read is a maintenance trap; a UI selector that records nothing (e.g. the 2_of_3 picker that stores a single integer) is worse — it teaches users a false model. Prefer deleting/hiding dead surfaces over building storage for an unbuilt feature.
  5. Migration-discipline. Per CLAUDE.md: new tables get explicit data-API GRANTs + RLS in the same migration; SECDEF functions SET search_path = '' + REVOKE PUBLIC + explicit GRANT; CREATE OR REPLACE re-derives from the current body and DROPs changed-signature overloads; ALTER TYPE … ADD VALUE is irreversible (you can never drop an enum value) and should not be used to speculatively expand a vocabulary no current row uses.

Cross-cutting concerns

These issues span multiple domains and must be owned centrally — several are verifiable in code today. A change in one of these has blast radius into several domains; assign ownership before any single domain's migration lands.

(i) Region is triple-modeled — unify toward ISO-3166-2 together

The same "where" concept has three incompatible representations in running code:

SurfaceRepresentationEvidence
clubs.regionRegion enum — 17 Korean SIDO english keys ('seoul''jeju')club.entity.ts:384; CHECK in 00040
venuescourt_venues.region = english SIDO key; public_courts.region = Korean label ('서울')00092 (key) vs seed_public_courts.sql (label) — the two venue tables diverge on encoding too
profilesstructured PlayRegion {sido, sigungu} JSONB, max 3profile.entity.ts:57; 00050

None is ISO-3166-2:KR. TwoMore's 17 keys map 1:1 onto ISO-3166-2:KR (seoul→KR-11, gyeonggi→KR-41, … sejong→KR-50) — the partition is identical, only the encoding differs. Owner action: treat region unification as ONE cross-domain workstream. The cheapest seam is to add a derived iso_region_code whenever expansion is greenlit (pure derivation, no data loss in waiting); do not add a third encoding (english key + Korean label + ISO code) piecemeal per table before the tables themselves are reconciled — that increases drift. The public_courts Korean-label discovery is the load-bearing correction: any SIDO→ISO backfill must not be run against public_courts as-is. (Forward-ref: Venues §, Identity §.)

(ii) gender ↔ club member_composition ↔ session mixed_doubles coupling

Three interdependent enums, currently unenforced against profile gender:

  • profiles.gender = 'male' | 'female' (binary) — profile.entity.ts:54, CHECK in 00049.
  • clubs.member_composition = 'male_only' | 'female_only' | 'mixed' | 'any'club.entity.ts:106 (collected, displayed, not enforced against members' gender).
  • SessionFormat includes 'mixed_doubles'session.entity.ts:16; the only live gender-driven logic is the rotation generator's genderMap: Map<string,'M'|'F'|null> (rotation.rules.ts:66), which already handles null gracefully.

Blast radius: widening gender (e.g. adding other/prefer_not_to_say) touches (a) genderSchema + update schema, (b) the 00049 DB CHECK — must be the same migration (a one-sided widen + Protocol-K coerceEnum silently corrupts writes to null), (c) the rotation genderMap (trivial: non-M/F → null), (d) the dormant getGenderAdjustedTier(elo, gender) percentile remap in tier.config.ts:301-360 (zero live callers but a second 'male'|'female' literal-union site a sweep would miss), (e) Match-Formats (SessionFormat) and Identity (member_composition) consistency. Owner: Player-Categories domain owns the gender enum; Skill-Tiers and Match-Formats are downstream consumers. (Forward-ref: Player-Categories §, Skill-Tiers §, Match-Formats §.)

(iii) Unified eligibility-gate shape in the RSVP RPC

Today only tier gating exists (clubs.min_tier/max_tier → inherited onto sessions, 00093/00151). The canonical precedent is the tier gate. Future age, gender, and division gates should share ONE enforcement shape in the RSVP RPC rather than each inventing a bespoke check. Recommendation: a single eligibility-predicate structure { dimension, op, value }[] evaluated server-side at RSVP time, so adding an age-bracket or gender-division gate is a new predicate row, not a new RPC branch. This is HELD (no consumer beyond tier yet) but must be designed before the second gate dimension ships so they don't diverge. Owner: Sessions/RSVP (bespoke-consistency) domain. (Forward-ref: Skill-Tiers §, Player-Categories §, Tournaments §.)

(iv) Shared-enum + cross-domain FK matrix

Which proposed entity/enum each domain shares. The HELD division(discipline, gender_category, age_bracket, adaptive_class) entity is the join point touching four domains — assign its ownership now so the four don't each build a partial version.

Proposed entity / enumIdentityRatingsSkill-TiersMatch-FormatsTournamentsPlayer-CatOwner
seasons table (promote from computed)✓ (season-scoped membership)✓ (season_records)✓ (draw scope)Tournaments/Phase 11
division (discipline×gender×age×adaptive)✓ (affiliation)✓ (rating namespace)✓ (competition bracket)✓ (mixed/quad draws)✓ (draw_entries)✓ (gender/age/adaptive)Skill-Tiers (defines), consumed by all
player_ratings (sport, discipline, namespace)✓ (seeding)Ratings
rating_discipline enum (singles/doubles/mixed_doubles)✓ (SessionFormat)Ratings
MatchFormatSpec (set/tiebreak/no-ad)✓ (game-pct math)✓ (rubber scoring)Match-Formats
ISO geo (country_code, iso_region_code)Cross-cutting (i)
canonical rsvp_response enum✓ (team ties)Bespoke-consistency
eligibility-gate predicate✓ (tier)✓ (division)✓ (age/gender)Bespoke-consistency (iii)

The division entity ownership is the single most important cross-cutting decision. It is referenced by Identity (a player's declared 부수/division), Ratings (a rating namespace), Skill-Tiers (the competition bracket), Match-Formats (mixed vs quad draw constraints), Tournaments (draw_entries), and Player-Categories (its gender/age/adaptive axes). Recommendation: Skill-Tiers owns the divisions table definition (it's fundamentally a skill/eligibility bracket); all others FK into it. Do not let Tournaments build a private bracket-division and Player-Categories build a private adaptive-division separately — they must be axes of one divisions row.


Domain 1 — Identity & Affiliations

Current state. Greenfield for identity beyond a flat Korean-only profile. profiles (00002, extended by 00049/00050/00174/00212) has display_name, avatar_url, phone, kakao_id, birth_year, gender, region (free TEXT), district, play_regions (JSONB). Zero nationality/country/citizenship column; grep for nationality|country|iso.?3166|utr|wtn|ipin|kata returns zero domain hits. No "primary club" — records-leaderboard-screen.tsx:45 is literally const primaryClub = clubs?.[0] (arbitrary). Seasons are computed from calendar dates, no DB table (season.entity.ts:4). Clubs are flat — no parent_id/federation/hierarchy. club_members uses a club_role enum, single is_active boolean (no temporal/season scope). (Correction from verdict: profiles has plain phone TEXT, never phone_e164.)

International standard (cited). ITF Tennis ID / IPIN (3 letters + 7 digits, career-immutable) is the global identity anchor; data exchanged in TODS (ITF-led, itftennis/tods) whose Person object uses nationalityCode = ISO 3166-1 alpha-3. UTR profile carries nationality as a 3-letter ISO code (SRB, USA); club affiliation is not embedded (separate endpoint). IOC and ISO codes diverge for some countries (Germany GER vs DEU) but agree for Korea: KOR — store ISO alpha-3. Korean amateur reality: KATA/KATO 부수 divisions (오픈부=마스터즈부, 신인부=챌린저부, 퓨처스) are the de-facto skill identity Korean club players quote — the highest-value KR-market field. KTA runs 시도협회 (provincial) affiliation.

Gaps. No nationality (blocks international/representing-country display); no external-rating identity link (KATA 부수 / UTR / WTN); no deterministic primary club; no affiliation hierarchy (시도협회→클럽); region is a Korea-only enum (the only real lock-in); membership has no season scope.

Recommendation — Ship now (flat). Verdict trims the finding's 5 columns to 3 — utr_id/wtn_id are dead schema today (no import plumbing, UTR API ToS-gated; admitted in Open Decision #3), and they cost nothing to add later. Ship on profiles:

  • nationality_code CHAR(3) NOT NULL DEFAULT 'KOR' CHECK (~ '^[A-Z]{3}$') — coarse, non-sensitive, ISO alpha-3.
  • kata_division TEXT CHECK (... IN ('newcomer','challenger','open','masters','futures')) — the 부수, English keys only (Korean labels via i18n). Highest-value KR field. (See Open Decision: 5-key vs 3 product buckets given the aliases.)
  • primary_club_id UUID REFERENCES clubs(id) ON DELETE SET NULL — backfilled to earliest active membership; stops the clubs[0] guess.

Recommendation — Held (generalized). A self-referential groups + group_memberships model (clubs/federations/sections/leagues as one hierarchy with parent_id, country_code, ISO-3166-2 region_code, season_id), is_primary partial-unique index replacing the flat primary_club_id, temporal joined_at/left_at replacing is_active, and a person_external_identities table (provider ∈ itf/utr/wtn/kta/usta/lta/ta) generalizing the rating-ID links (mirrors TODS one-Person-to-many-federation-IDs). Prerequisite: promote seasons from computed to a real table (cross-cutting iv). Held until inter-club leagues/seasons exist (Phase 11), non-Korean clubs onboard, or federation import is real. Flat→generalized is a mechanical backfill, no data loss.

Migration sketch (ship-now, 3 columns):

sql
ALTER TABLE public.profiles
  ADD COLUMN IF NOT EXISTS nationality_code CHAR(3) NOT NULL DEFAULT 'KOR'
    CHECK (nationality_code ~ '^[A-Z]{3}$'),
  ADD COLUMN IF NOT EXISTS kata_division TEXT
    CHECK (kata_division IS NULL OR kata_division IN
      ('newcomer','challenger','open','masters','futures')),
  ADD COLUMN IF NOT EXISTS primary_club_id UUID
    REFERENCES public.clubs(id) ON DELETE SET NULL;
-- backfill primary_club_id = earliest active club membership (DISTINCT ON, ORDER BY joined_at)
-- CREATE INDEX idx_profiles_primary_club ... WHERE primary_club_id IS NOT NULL;
-- CREATE OR REPLACE get_public_profile_view (re-derive from CURRENT 00221 body):
--   nationality_code + kata_division always-public; (utr_id/wtn_id deferred)

Open product decisions. (1) Nationality default 'KOR' NOT NULL + not consent-gated? (recommend yes). (2) KATA division — self-declared now (ships) vs verified against KATA (defer); 5-key enum or 3 product buckets (given 오픈부=마스터즈부, 신인부=챌린저부 aliases)? (3) UTR/WTN — store-only later vs display (needs import + ToS). (4) Primary club — user-chosen vs always-derived? (5) Promote seasons to a table on the Phase 11 path? (cross-cutting iv)


Domain 2 — Ratings & Rankings

Current state. Two parallel tier functions run in production, boundary-equivalent but not byte-identical (latent drift): getEloTier/ELO_TIERS (profile.entity.ts:17-33, inclusive max 899/1399/1999/2499 — drives most UI: leaderboard, podium, hero, public-profile, partner-suggester) vs getTierFromElo/TIER_DEFINITIONS (tier.config.ts:48-145, exclusive max — drives badge, tier-up, tier-info-sheet). Rating System v2 (elo.rules.ts + 00084/00085): real singles/doubles split, divisor 400, K-factors 40/32/24/48, 6 outcome types, scoreFactor 1.0/1.2/1.5, INITIAL_ELO_SEEDS 500–2100. RD is Glicko-in-name-onlysingles_rd/doubles_rd columns exist but never enter the expected-score or K math (DB RPC 00084:266,329-365 uses ratings only); RD only shrinks. Decay/inactivity is deadgrow-rd edge fn + decay_confidence_for_long_inactive exist but no cron.schedule invokes them, and grow-rd 403s in production. No UI surfaces rating_confidence/RD. club_elo_ratings (00017) seeds at 1000+firstDelta and only sums deltas (not a true Elo). Docs 00082/00083 narrate a stale 7-tier model (platinum/diamond) the live 5-tier code abandoned.

International standard (cited). Elo (unbounded, no native confidence/decay/margin). Glicko-2 (1500 + RD 350 + volatility σ 0.06; RD enters the E() and update math; RD grows during inactivity). UTR (1.00–16.50, separate S/D, reliability builds ~5 matches, 30 most-recent matches / 12-month rolling window, % games won). ITF WTN (40→1 inverted, Confidence 0–100% — high after ~15 sets/5 wks, falls to ~10% after a year inactive, a match every ~2 weeks keeps it high, set-level). NTRP (1.0–7.0, dynamic to 0.01; age/gender not in algorithm — only rating-validity period). Cross-mapping is genuinely contested — no source is authoritative; both UTR and USTA explicitly disclaim an official conversion. (Verdict correction: the finding's "USTA-authoritative NTRP 3.0 ≈ UTR 6.5–7.5" overclaims — it's a credible ballpark, not authoritative. Do NOT ship a map change on a "the official mapping says X" premise.)

Gaps. Two tier literals = drift landmine; cross-rating bands are approximate (label them so); RD/Glicko half-shipped (worst of both); decay dead (inactivity never raises uncertainty — opposite of WTN/UTR); no reliability surfaced; no recency weighting; no set granularity; club_elo_ratings mislabeled; no rating namespace/external-import concept.

Recommendation — Ship now (flat). Verdict prunes hard — the genuine low-regret wins:

  • Collapse to one tier function (code-only, no migration): delete ELO_TIERS, re-export getTierFromElo under the old name. Kills the dual-literal drift. Highest-value, ship independently.
  • Reframe cross-rating maps as "approximate display, no official conversion" (no map value change on a false-authority premise) + fix the doc drift (00082/00083 → note 5-tier reality).
  • Surface reliability as a pure derivation from eloMatchesPlayed (<5/5–19/20+ → provisional/developing/established pill) — no new column.
  • Decay/dead-cron: the honest flat-now move is delete or doc-as-inert the dead Glicko functions/columns, NOT wire them on (that's a live-rating behavioral change with no explanatory UI + a prod-403 guard to remove first — a HELD decision). (Verdict: do not "schedule the dead crons" as a quick fix.)
  • Defer rating_last_match_at/rating_computed_at columns + backfill — nothing consumes recency yet; that's held scope leaking into now.

Recommendation — Held (generalized). Normalized player_ratings (user_id, sport, discipline, namespace, algorithm, rating, rd, volatility, confidence, …)discipline ∈ singles/doubles/mixed_doubles, namespace ∈ global/club:<id>/region:<sido>/league:<id> (replaces the unsound club_elo_ratings), algorithm ∈ elo_v2/glicko2/imported_utr/imported_wtn (native + imported coexist), with real Glicko-2 (RD enters g(RD), σ). Plus external_ratings (system, value, as_of, source) and rating_match_results (set/game grain for UTR-style recency recompute). Held until Phase 11 multi-sport/international/external-import — the (sport, discipline, namespace) axis is classic speculative generality before then.

Open product decisions. (1) RD/Glicko real or dropped? (half-shipped is worst). (2) Surface reliability (3-state pill vs %)? (3) Adopt UTR-style 12-month/30-match recency, or keep lifetime sum? (4) Onboarding asks for real UTR/WTN/NTRP to seed? (5) club_elo_ratingsnamespace='club:<id>' when generalized? (6) Mixed doubles its own pool (UTR/WTN do NOT split; 혼복 culture may want it)?


Domain 3 — Skill Tiers & Divisions

Current state. Same dual-tier-function bug as Ratings (5 tiers × 3 sub-tiers III/II/I). Tier is 100% ELO-derived; no stored tier enum — but profiles.display_tier TEXT DEFAULT 'bronze' exists with no CHECK constraint (can hold legacy platinum), surfaced only on the web status page. Korean division converters (eloToKoreanDiv, eloToKoreanWomenDiv, eloToNtrp, eloToUtr) are dead code — exported, zero runtime consumers. getGenderAdjustedTier (z-score percentile remap) is inert — no pipeline computes GenderPoolStats. (Verdict: i18n is worse than stated — NTRP appears hard-coded in three places: tierDesc + tierNtrp + tierUtr, and they already disagree with the converter granularity.)

International standard (cited). NTRP 1.0–7.0 (0.5 published, dynamic to 0.01, bracketed, ~yearly). UTR 1.00–16.50 (continuous, separate S/D, reliable ~5 matches). WTN 40→1 inverted (separate independent S/D algorithms, explicit confidence). Korean 동호인 = 부서 (named divisions, not numeric) — KATO 7 (마스터즈/챌린져/베테랑 55+/지도자/위너스/국화/개나리), multi-axis (gender × age × skill), event-driven promotion (win 개나리부 → promoted to 국화부; can't jump >1 tier without board resolution). A division is eligibility, not a rating.

Gaps. Duplicate tier source; stale 7-tier spec doc; dead Korean converters (the vocabulary the market recognizes is invisible); no competition-division (부서) concept (the biggest KR-credibility + international gap); inert gender-adjusted tier; no external-rating interop fields; unconstrained display_tier; sub-tier barely surfaced.

Recommendation — Ship now (flat). Verdict: lead with A as independently shippable; trim WTN columns + the conversion-curve table.

  • (A) Collapse the duplicate tier definition (zero-migration code dedup) — the one true must-do; removes the live landmine. Ship independently of B/C/D.
  • (B) Add ntrp_self NUMERIC(2,1) + utr_self NUMERIC(4,2) (+ ntrp_verified BOOLEAN) — drop wtn_singles/wtn_doubles from flat scope (near-zero KR adoption; defer to held). Add korean_skill_div TEXT skill-axis-only (NOT the multi-axis collapse — see below), user-declared.
  • (C) Wire converters as "approximate skill equivalent" copy, not "your division" (rendering ELO-derived 부서 as identity contradicts the declared-eligibility model — Open Decision #1). Derive the i18n NTRP/UTR strings from the converter so the three hard-coded copies can't drift.
  • (D) Constrain display_tier to the 5-tier enum + drop the stale 'bronze' default.
  • (E) Keep tiers ELO-derived for matchmaking; 부서 is display/discovery metadata only.

Verdict correction on the flat korean_division enum: the finding's proposed enum jams gender (gaenari/gukhwa), age (veteran), and skill (sinin/open) into one flat TEXT CHECK — exactly the multi-axis collapse the held divisions table exists to avoid. Ship skill-axis-only (korean_skill_div) so it doesn't pretend to encode the full bracket.

Recommendation — Held (generalized). Normalized rating_systems + rating_conversions (NTRP/UTR/WTN/ELO as instances; direction ascending|descending for WTN) + a first-class divisions table (scope, gender_eligibility, min_age, max_age, min_tier, max_tier, sort_order) + division_memberships (promotion as event rows, KATO-style) + gender_pool_stats materialized view feeding the inert getGenderAdjustedTier. This divisions table is the cross-cutting (iv) join point — Skill-Tiers owns it. (Verdict: the rating_conversions curve table is over-built for 4 systems — keep the pure functions; promote to a table only if conversions become data-tuned per-region. Build last, maybe never.) Held until Phase 11.

Migration sketch (ship-now):

sql
-- (A) code-only: delete ELO_TIERS, re-export getTierFromElo
-- (B) profiles: ntrp_self NUMERIC(2,1) CHECK(1.0–7.0, half-steps), ntrp_verified BOOL,
--     utr_self NUMERIC(4,2) CHECK(1.00–16.50),
--     korean_skill_div TEXT CHECK(... IN ('newcomer','open','challenger','masters','futures'))  -- skill axis only
-- (D) ALTER profiles ALTER display_tier DROP DEFAULT;
--     ADD CONSTRAINT ... CHECK (display_tier IN ('bronze','silver','gold','master','grandmaster'))

Open product decisions. (1) 부서 user-declared vs auto-derived (recommend declared). (2) Women's strategy — gender-adjusted display vs separate 개나리/국화 divisions vs both? (3) Headline tier pool — doubles vs singles/doubles toggle vs blended? (4) Collect NTRP/UTR at onboarding for seeding? (5) Should 부서/tier ever gate competition (Phase 11 season engine)? (6) Recalibrate ELO boundaries on real KR data?


Domain 4 — Match Formats & Scoring

Current state. Score storage is single-set-only; the match_sets table exists but is fully dead code. matches stores one game-count pair (team1_score/team2_score) + one two-sided tiebreak pair (00147); format match_format is singles|doubles (team composition, 00001:9). match_rules JSONB (00147) + rules_snapshot describe a single Korean set; a matches_rules_snapshot_guard trigger locks it at in_progress. MATCH_RULES_FORMATS = ['1_set','2_of_3','3_of_5'] (match_rules.entity.ts:7) drives nothing2_of_3/3_of_5 appear in zero .sql files, only UI/i18n. A user can pick "5판 3선승" and the scorecard records a single integer pair (a lying selector). match_sets (00056) has the right shape + a full hex stack (entity, port, adapter, mapper, registry wire) but is written/read NOWHERE (zero callers of matchSets.*; no useMatchSets hook). ELO uses v_game_pct = max/total over the summed pair (correct for one set, wrong for best-of-N).

International standard (cited). ITF set formats — tiebreak set (6, win-by-2, TB at 6–6 to 7), advantage set, short set (4), Fast4 (4, TB at 3–3), 8/9-game pro set; match/super tiebreak (10-point, win-by-2) in lieu of deciding set (now standard at all Slams). No-ad/deciding point. Best-of-3/5. UTR/WTN store per-set game counts; UTR conventions: set TB = 1 game, 3rd-set match-TB = 2 games, competitiveness = % games won (external claim, not in-repo — confirm at implementation); WTN analyzes at set level.

Gaps. Cannot represent multi-set at all (6-4,4-6,7-5 collapses to one meaningless pair); match_sets dead (maintenance trap); format overloaded/inert; no set-type/match-TB-in-lieu model; ELO will silently mis-rate multi-set; no deuce/no-ad enforcement.

Recommendation — Ship now (flat). Verdict materially re-steers the finding here: do NOT ship a multi-set storage migration ahead of a real consumer. The honest, minimal flat-now move:

  • Resolve the dead-code + lying-UI first: either hide/disable the 2_of_3/3_of_5 selector (one-line UI gate, zero migration) until a real consumer exists, OR delete the dead match_sets stack to stop the maintenance trap. This is the lead recommendation, not a footnote.
  • Defer the match_format_type enum, the is_match_tiebreak flag, the RPC p_sets extension, and the set-aware ELO rewrite. Korea is single-set (95% near-term volume); building multi-set storage on spec rather than demand is premature. _(Verdict: do NOT repurpose team1_score/team2_score to mean "sets won" sometimes and "games" other times — that overloaded-semantics trap is exactly what the codebase's mapper discipline forbids; if multi-set ever ships, add explicit team_\_sets_won columns.)*

Recommendation — Held (generalized). Promote match_rules JSONB into a typed, composable MatchFormatSpec (Zod) + a match_format registry table of ITF presets (itf_tiebreak_bo3, grand_slam_bo5_10pt_final, match_tiebreak_in_lieu_bo3, pro_set_8, fast4_bo3, korean_single_set). matches.format_id FK; activate match_sets as the universal per-set store (is_match_tiebreak, set_kind); a single scoring engine in domain/rules/ validates set/match completion + deuce/no-ad (replacing the ad-hoc score-edit-sheet.tsx heuristics); ELO reads match_sets + spec game-weighting (UTR convention codified per-format, after confirming the external weighting claim). The held registry carries a discipline column for Phase 11 multi-sport. This is the fully-designed-but-HELD artifact — keep in docs/specifications/. Held until Phase C / a Korean club actually plays best-of-3.

Open product decisions. (1) Delete vs wire the dead match_sets stack (the real lead decision). (2) Ship best-of-3 to Korean clubs now, or hide the selector until Phase C? (3) Match-TB-in-lieu near-term KR need or international-only? (4) No-ad/deuce enforcement ever, or stay set-score-only forever? (5) ELO recalibration sign-off (UTR weighting touches submit_match_score)? (6) Disciplines-beyond-tennis discipline column from day one in the held registry?


Domain 5 — Tournaments & Competition Structures

Current state. Two parallel disconnected models + a sophisticated client engine with no relational draw schema. (1) tournaments (00097) — UNIQUE(session_id) (one tournament per session, hard-coupled), the entire bracket lives in opaque state JSONB (tournament.entity.ts:30 types it Record<string, unknown>); no bracket/seed/standing tables, not SQL-queryable. (2) Rich client-side rules engine (bracket.rules.ts folded seed order [1,8,5,4,3,6,7,2], swiss.rules.ts buchholz + ceil(log2(N)), compass.rules.ts E/W/N/S, tournament.rules.ts round-robin) — pure, tested, but serialized into the JSONB, never persisted as rows. (3) matches carries only round_numberno tournament_id/bracket_position/feeds_to. (4) inter_club_challenges (00098) is a date-window metric aggregate, NOT a tie/rubber (no roster, no designated rubbers). get_inter_club_score is brokencount(*) of ALL completed matches (not wins), ignores challenge_type, double-counts, and stores into vars named v_home_wins/v_away_wins (naming-vs-logic mismatch). (Verdict: the separate increment_club_challenge/club_challenges is a different monthly-gamification feature — don't conflate.)

International standard (cited). ITF/USTA draws — single-elim (folded seeding, byes), round-robin (snake into A–H groups), group+knockout, compass (8-direction, everyone plays 3–4), consolation/feed-in (FIC), First Match Back (FMB). USTA RR tiebreaker cascade: match W/L → head-to-head → sets ratio → games ratio → % → coin flip. Davis/BJK Cup tie/rubber: a tie = team-vs-team; a rubber = one match within it (classic 4 singles + 1 doubles, win 3 of 5); model = a tie has an ordered set of rubbers (rubber_type, rubber_order, lineup), winner derived from rubber wins. Box leagues/ladders — flighted RR pools, per-match points (illustrative 5/4/2/1/0), two-up/two-down promotion/relegation.

Gaps. Brackets opaque-JSONB not queryable; tournament hard-coupled to one session; no tournament↔match FK (can't attribute a scored match to a slot); inter-club is a metric not a tie; get_inter_club_score incorrect; no seeding persistence; no consolation/FIC/FMB; no SQL tiebreaker cascade; no box-league model.

Recommendation — Ship now (flat). Verdict: the truly minimal flat ship is items #1 + #2; item #3 (tie/rubber) is a feature, not a schema cleanup — defer behind product sign-off.

  • (1) Fix get_inter_club_score to count actual member wins per challenge_type (correctness; harden to SET search_path = '' per SECDEF discipline). Land regardless.
  • (2) Add tournament_id FK + bracket-slot columns to matches (bracket_round, bracket_position, feeds_winner_to_match, is_competitive) so the existing client engine's matches become real, attributable, queryable rows. Flat columns, no new bracket table; state JSONB stays as the shape cache.
  • Defer #3 (inter-club tie/rubber home_lineup[]/away_lineup[] + competition_format discriminator) — it introduces lineup UX + the unresolved cross-club attribution rule (Open Decision #3) + a rubbers_to_win derivation. Gate behind explicit product sign-off.

Recommendation — Held (generalized). competitions umbrella (kind ∈ tournament/box_league/ladder/team_tie/inter_club_series, scope, spans 0..N sessions) + draws (draw_type, parent_draw_id for consolation/feed-in chains) + draw_entries (persisted seed, seed_source) + draw_matches (normalized slots, feeds_winner_to/feeds_loser_to, FK to real match) + standings (full USTA tiebreaker fields, trigger-refreshed) + team_ties + rubbers (Davis-Cup) + box-league tables. (Verdict: feeds_loser_to + consolation/FIC + full sets_w/l/games_w/l standings are the biggest complexity multipliers for zero current demand — keep firmly held; full USTA cascade needs per-set persistence the app lacks.) Held until multi-session events / leagues are greenlit. FKs into seasons (cross-cutting iv).

Migration sketch (ship-now):

sql
-- (1) CREATE OR REPLACE get_inter_club_score(uuid) ... SECURITY DEFINER SET search_path=''
--     count member WINS per challenge_type branch; REVOKE PUBLIC; GRANT authenticated.
-- (2) ALTER matches ADD tournament_id uuid REFERENCES tournaments(id) ON DELETE SET NULL,
--     bracket_round int, bracket_position int,
--     feeds_winner_to_match uuid REFERENCES matches(id) ON DELETE SET NULL,
--     is_competitive bool NOT NULL DEFAULT true;
--     CREATE INDEX ... ON matches(tournament_id, bracket_round) WHERE tournament_id IS NOT NULL;

Open product decisions. (1) Seed source — club ELO / global ELO / manual / entry order? (2) Inter-club v1 — corrected metric only, or tie/rubber match-day now? (3) Cross-club match attribution rule (mixed sessions with members of both clubs on one team — genuinely hard). (4) Tournament spans multiple sessions (drop UNIQUE(session_id))? (5) Consolation/FIC/FMB near-term ("everyone plays more")? (6) Box leagues a planned product? (7) Full USTA tiebreaker cascade now (needs per-set storage from Match-Formats)? (8) ITF-named i18n rounds from day one?


Domain 6 — Venues & Courts

Current state. Two parallel divergent court tables + reviews; no shared surface/court taxonomy; no pace. (1) court_venues (club-owned, the table sessions use, 00014) — surface_type is a strict ENUM ('hard','clay','grass','indoor','other'); indoor is mis-modeled as a surface (it's an environment dimension) and is also a separate has_indoor BOOLEAN (00025) — modeled twice. (2) public_courts (national directory, 00032) — surface_type TEXT CHECK with a different set ('hard','clay','grass','carpet','artificial_grass','other') (no indoor) + court_type ('indoor','outdoor','both') (the env dimension done right) + gov-enrichment fields. The two tables' surface vocabularies don't match. (Verdict — load-bearing correction: the finding's "region stored everywhere as english SIDO key" is false for public_courts, which stores Korean labels ('서울', '강남구') per seed_public_courts.sql. So the two venue tables diverge on region encoding toocourt_venues=english key, public_courts=Korean label.) No court-pace/CPR concept anywhere; no per-physical-court entity (court_count only; matches.court_number is a bare int slot).

International standard (cited). ITF surface taxonomy — 10 construction-only types: acrylic/polyurethane, artificial clay, artificial grass, asphalt, carpet, clay, concrete, grass, hybrid clay, other (ITF does not publish stable A–J letter codes). ITF Court Pace Rating (CPR) — a separate, orthogonal performance dimension: 5 categories slow→fast (≤29 / 30–34 / 35–39 / 40–44 / ≥45). Indoor/outdoor is a third orthogonal dimension (never a surface). ISO-3166-2:KR — 17 codes (KR-11 Seoul … KR-50 Sejong), 1:1 onto TwoMore's keys; level-2 standard is the Korean 행정표준코드 (10-digit).

Gaps. Surface taxonomy diverges from ITF and from itself; indoor mis-modeled; no pace; region not ISO (and the two venue tables disagree on encoding); two tables that should be one; sub-district is free Korean text.

Recommendation — Ship now (flat). Verdict: pragmatic v1 = fix the indoor mismodel + add the environment & (optionally) pace dimension; do NOT irreversibly expand the surface enum or add ISO/pace columns no current data can fill (the seed corpus is 100% 'hard').

  • Add court_environment TEXT CHECK (... IN ('indoor','outdoor','both')) to court_venues (the missing orthogonal dim; mirrors public_courts.court_type) and migrate surface_type='indoor' rows → surface_type='hard' + court_environment='indoor' (also derive from the legacy has_indoor boolean). This is the core fix.
  • Defer the full ITF surface ALTER TYPE … ADD VALUE expansion — it's irreversible, pollutes the enum forever, and serves zero current rows (verdict). At most reconcile court_venues with public_courts by adding carpet/artificial_grass. The full ITF set belongs in the held surface_types lookup table, not an ALTER TYPE.
  • Defer court_pace SMALLINT and iso_region_code — pace contradicts its own 3-bucket-display recommendation and no KR court has a lab CPR; ISO tagging is a pure derivation available whenever expansion lands, and adding it now creates a third region encoding before the tables are unified (cross-cutting i).

Recommendation — Held (generalized). Unify into a two-level venues (facility) + courts (per physical court: surface, environment, pace, label "1번 코트") model; court_venues + public_courts fold into venues via a source discriminator; matches.court_number → FK courts.id. Geo generalized to ISO (country_code CHAR(2), iso_region_code as primary key, admin_code for 행정표준코드). Pace as cpr_value REAL + a generated court_pace SMALLINT. Surface as a real surface_types lookup/seed table keyed by ITF name + i18n label + default pace category (so ITF revisions are row inserts, not ALTER TYPE). Held until court-booking (Roadmap 8) / international (11).

Migration sketch (ship-now, trimmed):

sql
ALTER TABLE public.court_venues
  ADD COLUMN IF NOT EXISTS court_environment TEXT
    CHECK (court_environment IN ('indoor','outdoor','both'));
UPDATE public.court_venues
   SET surface_type = 'hard', court_environment = 'indoor' WHERE surface_type = 'indoor';
UPDATE public.court_venues
   SET court_environment = 'indoor' WHERE court_environment IS NULL AND has_indoor = true;
-- (optional reconcile) ALTER TYPE surface_type ADD VALUE IF NOT EXISTS 'carpet';
--                      ALTER TYPE surface_type ADD VALUE IF NOT EXISTS 'artificial_grass';
-- NOTE: ALTER TYPE ADD VALUE cannot run + be used in the same txn — split out if db reset complains.
-- DEFER: court_pace, iso_region_code, full ITF surface expansion → held layer.

Open product decisions. (1) Keep hard as an umbrella surface value (most KR courts are colloquially "하드")? (2) Pace exposure — 3-bucket 느림/보통/빠름 vs full ITF 1–5 (and is it worth storing at all yet)? (3) Can sessions reference public_courts directly (pulls the held venues unification forward)? (4) International timing for country_code/ISO geo? (5) Per-court modeling needed for v1 booking/check-in, or is court_count + matches.court_number enough? (6) Adopt 행정표준코드 for sub-district?


Domain 7 — Player Categories (gender / age / handedness / adaptive)

Current state. Profile has gender + birth_year only; zero handedness / age-division / adaptive fields. Gender = 'male'|'female' (binary) at entity, schema, DB CHECK (00049), anonymize (00184:85 NULLs it), and visibility (00212 defaults gender + birthYear 'private', unknown-key fallback 'club_members'). birth_year is immutable + under-14-gated by trigger reject_under14_birth_year (00186, PIPA §22-2, HINTs under14_blocked/birth_year_immutable). The one live gender consumer is the rotation generator's genderMap (rotation.rules.ts:66), already null-safe; ELO is gender/age-blind. (Verdict: getGenderAdjustedTier in tier.config.ts:301-360 is a second 'male'|'female' literal-union site — dormant/zero callers, but a widening sweep must touch it.) Club-level MemberComposition exists but isn't enforced against member gender.

International standard (cited). Age divisions — ITF Masters 5-yr increments (30…90+); USTA rankings 30–100, League 18/40/55/65 & Over; ITF Juniors U12/U14/U16/U18 by year-of-birth, no entry until 13th birthday (below the PIPA §22-2 under-14 floor). Pattern: "X & over"/"U-X" thresholds derived from birth year, not stored. Gender — competition M/W with Mixed as a doubles event/pairing constraint, not a player attribute; inclusive platforms add self-ID (other/prefer_not_to_say) distinct from draw-eligibility. Handedness — two orthogonal fields: plays (right/left) + backhand (one/two-handed); non-sensitive, ~15% lefty. NTRP not adjusted by age/gender (only rating-validity period). ITF wheelchair/adaptiveOpen (M/W draws) vs Quad (single mixed-gender draw); sensitive health data (PIPA §23).

Gaps. No handedness (the cheapest, lowest-risk, non-PIPA, internationally-standard attribute, fully absent); gender is hard binary at 5 layers; no age-division derivation; no adaptive/quad model; no backhand style; must preserve the existing correct separation of pairing constraint (mixed_doubles) from player attribute (gender).

Recommendation — Ship now (flat). Verdict: ship handedness only — backhand_style is scope-creep (no consumer, no display surface requested today; the finding itself hedges it as optional).

  • Add Profile.handedness?: 'right'|'left' | null — nullable TEXT + permissive CHECK (mirrors the 00049 gender pattern), not visibility-gated (non-sensitive, like displayName), mapper via coerceEnum (Protocol K), surfaced in edit-profile-screen.tsx. Informational now; the rotation generator can later weight lefty/righty balance like it weights gender.
  • Explicitly DO NOT ship now: backhand_style, gender-widening, age-division, adaptive.

Recommendation — Held (generalized). (A) Gender widening — to z.enum(['male','female','other','prefer_not_to_say']) AND the DB CHECK in the same migration (Protocol-K drift makes a one-sided widen silently corrupt writes); keep a separate competitionGender if international M/W draws need a forced-binary mapping (self-ID ≠ draw-eligibility); the rotation genderMap + dormant getGenderAdjustedTier map other/pnts → null. Held until an inclusive-onboarding decision or a divisional consumer. (B) Age-division helperpure domain helper, no schema — lead with a coarse ageBucket → 'junior'|'adult'|'senior' (PI-light given birthYear='private'); the scheme (USTA vs ITF vs Korean-local) is TBD by the first consumer — don't pre-commit two foreign schemes (verdict). (C) Adaptive/quad — HELD indefinitely; sensitive PIPA §23 health data → a separate consent-gated player_adaptive_classification table (NOT flat profile columns), purpose_catalog row + policy_versions bump, off the data API. Verdict: don't carry the full DDL — a one-line "if para-tennis ships: separate consent-gated table" is the right altitude; premature schema is highest-risk for sensitive data.

Migration sketch (ship-now, handedness only):

sql
ALTER TABLE public.profiles
  ADD COLUMN IF NOT EXISTS handedness TEXT DEFAULT NULL
    CHECK (handedness IS NULL OR handedness IN ('right','left'));
COMMENT ON COLUMN public.profiles.handedness IS 'Dominant playing hand. Non-sensitive, freely editable.';
-- entity: Handedness type + handednessSchema; Profile/UpdateProfileInput/updateProfileInputSchema fields;
-- mapper coerceEnum(handednessSchema, row.handedness, null); no anonymize/visibility/trigger change.

Open product decisions. (1) Handedness only, or + backhand (recommend only). (2) Add inclusive gender to onboarding now (graduates from held; still needs both-sides migration)? (3) Age-division scheme when it lands (USTA / ITF / Korean-custom)? (4) Junior floor conflict — ITF juniors start at 13 but §22-2 blocks under-14; acceptable for KR (yes) or does international need a legal-guardian-consent path? (5) Is para-tennis ever in scope (if not, drop the held design)? (6) Weight handedness in matchmaking eventually?


Domain 8 — Bespoke domains consistency (sessions / RSVP / attendance / trust / dues)

Current state. "People + events + accountability" split across native ENUMs (older tables: session_status, rsvp_status = confirmed/waitlisted/cancelled/no_show, match_status, payment_status, club_role) and TEXT+CHECK (Phase-6: trust_tier, attendance_records.final_status, confirmed_by, strike_value REAL CHECK IN (0,0.5,1.0), attendance_records.rsvp_status CHECK IN ('going','not_going','maybe')). Three concurrent RSVP vocabularies: the rsvp_status ENUM; attendance_records.rsvp_status = going/not_going/maybe (a different set in a same-named column, exposed as untyped rsvpStatus: string); ClubEvent RSVP = attending/not_attending. Two parallel event systemsSession/Rsvp (primary, rich) vs ClubEvent/ClubEventRsvp (00076) which has a full data-layer stack but zero feature/screen consumersdormant dead weight, not a live competing system (verdict). Audit-actor naming chaos: clearedBy (dues) vs recordedBy (session_payments + PaymentRecord) vs clearedByUserId (UpdateDuesStatusInput) — three names for "the admin who recorded a payment"; confirmedBy/approvalMethod overload "By" to mean a method enum, not a principal. State machines are scattered plpgsql guards with no single declared transition contract. Dues period is flat year/month integers. (Verdict — material correction: the finding's "rsvp_status.no_show is dead / no RPC writes it" is FALSE — a live adapter method markNoShow (rsvp.repository.port.ts:41, rsvp.supabase.ts:120-123) does .update({ status: 'no_show' }). It has zero screen callers, so it's dormant/unwired, not non-existent.)

International standard (cited). No tennis-specific schema standard exists; the anchors are generic event vocabularies. iCalendar RFC 5545 §3.2.12 PARTSTAT — attendee response: NEEDS-ACTION/ACCEPTED/DECLINED/TENTATIVE/DELEGATED (+ COMPLETED/IN-PROCESS for VTODO) — cleanly separate from event lifecycle. schema.org EventStatusType (EventScheduled/Cancelled/Postponed/Rescheduled/MovedOnline) + RsvpResponseType (Yes/No/Maybe) — deliberately separate types. Recurrence as RRULE, not a hand-rolled {frequency, dayOfWeek}. Pattern: lifecycle status and attendee-response status are orthogonal axes (TwoMore's Session.status vs Rsvp.status follows this; final_status is a correct third "what actually happened" axis).

Gaps. Two event systems (one dormant); three RSVP vocabularies (one untyped, name-colliding); dormant unwired no_show write path; mixed ENUM/TEXT storage convention; audit-actor naming chaos; no declared state-machine contract; non-standard recurrence subset; flat year/month dues; magic REAL strike weight.

Recommendation — Ship now (flat). Domain-only naming/contract cleanups, no SQL migration, no behavioral change:

  • One canonical "who-did-this" convention: <verb>By: ID = principal; <verb>Method = how-enum. Alias session_payments + dues PaymentRecord to expose recordedBy; rename UpdateDuesStatusInput.clearedByUserId → recordedBy (keep DB column, map in mapper). Document confirmedBy/approvalMethod as method-enums so no fourth synonym appears.
  • Type the third RSVP vocabulary: AttendanceRsvpStatus = 'going'|'not_going'|'maybe' (replace untyped string), doc it as the snapshot-at-attendance response.
  • Deprecate rsvp_status.no_show in the entity (don't drop the enum value) — AND delete the unwired markNoShow adapter method (verdict: otherwise the deprecation is cosmetic and the proposed arch-test would fail today).
  • Name the strike weight: StrikeWeight = 0|0.5|1.0 with STRIKE_NONE/LATE/NOSHOW constants.
  • State-machine-as-test: ONE minimal allowed-transition test pointing at the authoritative migrations (verdict: don't re-encode full trigger logic in two TS files — drift risk).

Recommendation — Held (generalized). Unify Session + ClubEvent under one gatherings table (gathering_kind, 1:1 match_session_detail extension) + a single canonical rsvp_response ENUM ('yes','no','maybe','waitlisted') (schema.org-aligned). Verdict: given ClubEvent has zero UI consumers, the pragmatic move is to delete the dormant stack, not build a supertype to unify a system nobody uses — gate any unification on leagues actually shipping. Add postponed/rescheduled lifecycle states; RRULE + EXDATE recurrence (we have korean-holiday.entity.ts); billing-period dues (period_start/end DATE, period_kind). A generic participation_record is likely never (over-generalizing clean per-concept tables is the trap — both finding and verdict agree). The canonical rsvp_response enum is cross-cutting (iv).

Migration sketch (ship-now, domain-only — no SQL):

ts
// trust.entity.ts
export const ATTENDANCE_RSVP_STATUSES = ['going', 'not_going', 'maybe'] as const;
export type AttendanceRsvpStatus = (typeof ATTENDANCE_RSVP_STATUSES)[number];
export const STRIKE_WEIGHTS = { none: 0, late: 0.5, noShow: 1.0 } as const;
// session.entity.ts: RsvpStatus ... 'no_show' @deprecated  + DELETE markNoShow port/adapter method
// dues.entity.ts: UpdateDuesStatusInput.clearedByUserId -> recordedBy (map to cleared_by in mapper)

Open product decisions. (1) Consolidate ClubEvent into Session, keep separate, or delete the dormant stack? (at minimum align its RSVP to the canonical vocabulary). (2) International leagues timeline (Phase 11) — promote gatherings/RRULE from held to next? (3) Dues staying monthly-only? (4) Reschedule/postpone UX (→ lifecycle states)? (5) Confirm recordedBy as the winning audit-actor name.


Consolidated roadmap

#DomainProposed changeShip-now / HeldTriggering feature / phaseRisk
1Identityprofiles.nationality_code + kata_division + primary_club_idShip-nowKR-market identity + leaderboard determinismLow
2Identitygroups/group_memberships/person_external_identities + seasons tableHeldPhase 11 leagues / int'l onboarding / federation importHigh
3RatingsCollapse dual tier fn (ELO_TIERSgetTierFromElo)Ship-nowDrift-prevention (code-only)Low
4RatingsReliability pill (derive from match count) + cross-map "approximate" reframe + doc-drift fixShip-nowHonest rating displayLow
5RatingsDelete/doc-as-inert dead Glicko RD + decay cronsShip-nowStop claiming unrun GlickoLow
6Ratingsplayer_ratings/external_ratings/rating_match_results + real Glicko-2HeldPhase 11 multi-sport / int'l / external importHigh
7Skill-Tiers(A) collapse dual tier fn — same as #3Ship-nowDrift-preventionLow
8Skill-Tiersntrp_self + utr_self (+ ntrp_verified) + korean_skill_div (skill-axis-only)Ship-nowKR identity + future seedingLow
9Skill-TiersWire converters as "approx skill equiv" + dedupe i18nShip-nowKR vocabulary displayLow-Med
10Skill-TiersConstrain display_tier to 5-tier enumShip-nowData integrityLow
11Skill-Tiersdivisions + division_memberships + gender_pool_stats + rating_systemsHeldPhase 11 / KATA-credible competitionHigh
12Match-FormatsResolve dead-code + lying-UI: hide 2_of_3 selector OR delete dead match_sets stackShip-nowStop the lying selectorLow
13Match-FormatsMatchFormatSpec + match_format registry + activate match_sets + scoring engineHeldPhase C / KR best-of-3 actually playedHigh
14TournamentsFix get_inter_club_score (count wins, honor type, harden SECDEF)Ship-nowCorrect scoreboard (bug)Low-Med
15Tournamentsmatches += tournament_id + bracket-slot columnsShip-nowQueryable/attributable drawsMed
16TournamentsInter-club tie/rubber (*_lineup[] + competition_format)Held (product sign-off)Club-vs-club match daysMed
17Tournamentscompetitions/draws/draw_matches/standings/team_ties/rubbers/box-leaguesHeldPhase 11 multi-session / leaguesHigh
18Venuescourt_venues.court_environment + migrate indoor surface rowsShip-nowIndoor/outdoor filter; fix mismodelLow-Med
19Venues(optional) add carpet/artificial_grass to reconcile surface enumsShip-nowCross-table consistencyLow (irreversible)
20Venuesvenues+courts unification + ISO geo + surface_types lookup + paceHeldCourt-booking (Roadmap 8) / int'l (11)High
21Player-Catprofiles.handednessShip-nowProfile info; future matchmaking weightLow
22Player-CatGender widening (both-sides migration)HeldInclusive onboarding / divisional consumerMed
23Player-CatageBucket helper (coarse, no schema)HeldSenior/junior league / age-filtered discoveryLow
24Player-Catplayer_adaptive_classification (consent-gated, §23)Held (likely never)Real para-tennis productHigh (sensitive PI)
25BespokeAudit-actor naming convention (recordedBy) + type AttendanceRsvpStatus + StrikeWeightShip-nowConsistency (domain-only)Low
26BespokeDeprecate no_show + delete unwired markNoShowShip-nowStop dormant write pathLow
27BespokeState-machine-as-test (one minimal contract)Ship-nowExecutable transition contractLow
28Bespokegatherings unification / RRULE / billing-period dues / canonical rsvp_responseHeld (or delete ClubEvent)Phase 11 leagues / reschedule UX / non-monthly duesHigh

ATP epic linkage

Two roadmap epics map directly onto the ship-now layers of two domains.

Phase B — Profile affiliations (Identity + the gender enum): ship the flat layer now — nationality_code + primary_club_id + kata_division on profiles (plus utr_id/wtn_id, per the Identity domain, only once a cross-platform linking feature exists). Schema requirement: 3 nullable/defaulted columns on profiles + the get_public_profile_view re-export; held = the generalized groups/group_memberships/person_external_identities graph (Identity domain #2), promoted only when Phase 11 leagues/seasons land. → Domains: Identity (primary), Player-Categories (the gender enum it must not corrupt).

Phase C — Flexible multi-set scoring (Match-Formats): ITF-aligned best-of-N + match-tiebreak-in-lieu, incl. dev scenarios. Schema requirement: activate the already-built match_sets as the per-set store + the typed MatchFormatSpec registry + the set-aware ELO game_pct (UTR weighting) — all currently HELD; the only ship-now Phase-C-prep step is resolving the lying 2_of_3 selector / dead match_sets stack (#12) so the foundation isn't a maintenance trap when Phase C begins. → Domains: Match-Formats (primary), Ratings (the game-pct math it changes), Tournaments (rubber scoring reuses it).


Open decisions for the product owner (consolidated)

Cross-cutting (decide first — these gate multiple domains):

  • Own the divisions entity. It's the join point for Identity/Ratings/Skill-Tiers/Match-Formats/Tournaments/Player-Categories. Recommendation: Skill-Tiers owns the table; all others FK in. Don't let Tournaments and Player-Categories each build a partial private version.
  • Region unification strategy — accept ISO-3166-2 as the eventual single encoding; do NOT add a third encoding piecemeal; reconcile the court_venues(english-key) vs public_courts(Korean-label) divergence as ONE workstream.
  • Promote seasons from computed to a table? — prerequisite for season-scoped membership (Identity), draw scope (Tournaments), gatherings recurrence (Bespoke). On the Phase 11 path?
  • Unified eligibility-gate shape in the RSVP RPC before the second gate dimension (age/gender/division) ships — so they don't diverge from the tier-gate precedent.

Per-domain:

  • Identity: nationality default 'KOR' NOT NULL? KATA division 5-key vs 3 product buckets, self-declared vs verified? Primary club user-chosen vs derived?
  • Ratings: RD/Glicko real or dropped (half-shipped is worst)? Surface reliability (pill vs %)? Recency window (12-mo rolling vs lifetime)? Onboarding collects real UTR/WTN for seeding?
  • Skill-Tiers: 부서 declared vs ELO-derived? Women's strategy (gender-adjusted display vs 개나리/국화 divisions vs both)? Headline tier pool (doubles/singles/blended)? Should tier/부서 ever gate competition?
  • Match-Formats: Delete vs wire the dead match_sets stack (lead decision)? Ship best-of-3 to KR clubs now or hide the selector until Phase C? No-ad/deuce enforcement ever?
  • Tournaments: Seed source (club vs global ELO vs manual)? Inter-club v1 = corrected metric only vs tie/rubber now? Cross-club match attribution rule? Drop UNIQUE(session_id) for multi-day events? Consolation/FIC near-term?
  • Venues: Keep hard umbrella? Pace exposure (3-bucket vs ITF 1–5, or store none yet)? Can sessions reference public_courts directly? Per-court entities needed for v1 booking?
  • Player-Categories: Handedness only or + backhand (recommend only)? Inclusive gender in onboarding now? Age-division scheme (USTA/ITF/KR-custom)? Junior 13-vs-14 PIPA floor — acceptable for KR or needs guardian-consent path? Para-tennis ever in scope?
  • Bespoke: Delete the dormant ClubEvent stack vs consolidate vs keep-separate? Confirm recordedBy as the canonical audit-actor name. Dues monthly-only? Reschedule/postpone UX (→ lifecycle states)?

Sources (deduped, grouped by domain)

Cross-cutting / Geography:

  • ISO 3166-1 alpha-3 (Wikipedia); Comparison of IOC/FIFA/ISO codes (GER vs DEU; KOR=KOR)
  • ISO 3166-2:KR full 17-code table (Wikipedia)

Identity & Affiliations:

  • ITF World Tennis Number — Features / System rules; ITF — About IPIN
  • Tennis Open Data Standards (TODS) — Confluence overview, Standard Codes, Participants intro; GitHub itftennis/tods
  • UTR API Documentation (player profile, nationality ISO-3); Universal Tennis Rating (Wikipedia)
  • 대한테니스협회 KTA 통합시스템; KTA 생활체육 복식 랭킹관리지침 2024 (PDF); 한국동호인테니스협회 KATA 랭킹안내; KATO 한국테니스발전협의회 규정

Ratings & Rankings:

  • ITF WTN — Confidence Level explained / Breakdown / FAQ; USTA WTN FAQs
  • Glicko rating system (Wikipedia); Glickman, Example of the Glicko-2 system (PDF)
  • UTR — How the rating is calculated (30 matches, 12-mo window); UTR (Wikipedia); UTR Algorithm Complete Summary
  • USTA NTRP Ratings FAQ (dynamic vs year-end, age-validity, age/gender not in algorithm)
  • UTR vs WTN vs NTRP (Universal Tennis blog; tennisnerd) — cross-map is contested, no official conversion

Skill Tiers & Divisions:

  • USTA — Understanding NTRP Ratings; NTRP General Characteristics (PDF); NTRP FAQs (dynamic 0.01)
  • Universal Tennis — How UTR Works; Algorithm Complete Summary
  • ITF World Tennis Number — How WTN Works (40→1, separate S/D)
  • KATO — 참가자격규정 / katoRules (7 divisions + 승급); 오마이뉴스 — 개나리·국화 여성부 명칭
  • TennisAcademy — UTR vs WTN vs NTRP 2026; Tennisnerd — rating systems explained

Match Formats & Scoring:

  • Tennis scoring system (Wikipedia) — advantage/tiebreak set, 7-pt TB, 10-pt match TB, no-ad, pro set, short set, best-of-3/5
  • Fast4 Tennis (Wikipedia); UTR Posting Scores Guidelines + How to Record a Tiebreak; UTR Algorithm Summary
  • ITF WTN FAQ + USTA WTN FAQs (set-level); Paris 2024 Olympic Tennis Event Regulations (ITF PDF — 10-pt final-set TB)

Tournaments & Competition Structures:

  • ITF World Tennis Tour Juniors 2026 Regulations (PDF — RR group seeding)
  • Compass Draw overview; UTR Sports — Feed-In Consolation / Single Elimination & First Match Back
  • Davis Cup official Format + Wikipedia (tie/rubber, 3-of-5)
  • USTA — Round Robin Order of Finish; Print Your Brackets — RR tiebreaker rules
  • Matchspace — Box League vs Ladder; LTA — Box Leagues; Global Tennis Network — Ladder League software

Venues & Courts:

  • ITF — About Court Pace Classification; Tennis Surfaces Classification; Surfaces/Surface Types (PDF, Nov 2019); 2025 Technical Booklet (PDF)
  • Pacecourt — ITF Classifications & CPR thresholds; Tennisnerd — ITF Classified Surfaces overview
  • Wikipedia — Tennis court (surface/environment dimensions)

Player Categories:

  • USTA Adult Tournament & Family Regulations 2025 (PDF); Adult Tournaments Eligibility; USTA League age groups (18/40/55/65)
  • ITF World Tennis Masters Tour Regulations 2025 (PDF); World Tennis Tour Juniors; Junior Team Competitions (U14/U16)
  • ITF — What is Wheelchair Tennis Classification (Open vs Quad); ITF Wheelchair Classification Rules 2026 (PDF); LTA Wheelchair Classification
  • USTA NTRP FAQ (age-validity; gender/age not in algorithm)
  • Tennis Abstract — ATP Lefthander Rankings (~15%); On the Gender Effects of Handedness in Professional Tennis (PMC)

Bespoke-consistency (sessions/RSVP/attendance/trust/dues):

  • iCalendar RFC 5545 §3.2.12 PARTSTAT; RFC 5545 full spec
  • schema.org EventStatusType; RsvpResponseType; Event (subEvent/superEvent/eventSchedule)

Markdown remains the source of truth. Run yarn docs:check before handoff.