Skip to content

Foundational-Schema Decisions

Status: Reference — Decided positions + library selections + ordered implementation plan. This is the ACTIONABLE resolution of foundational-schema-audit.md.

Read this with the audit. The audit (foundational-schema-audit.md) is the planning artifact — it grounds each domain in international tennis standards and separates ship-now flat columns from held generalized models, but commits to nothing. This doc RESOLVES that audit into decided positions: it pairs each domain's standards-alignment with a concrete library choice (or a documented custom-over-library verdict), pins versions and column names, and sequences the work into small, independently-shippable migrations tied to ATP Phase B (profile affiliations) and Phase C (flexible multi-set scoring).

Still not a batch. Every SHIP-NOW item is its own migration/commit against its motivating feature. Every HELD item stays a docs/specifications/ target until its triggering feature lands. Where a source recommendation conflicted with its adversarial verdict, the verdict's accuracy correction wins (the library-claim corrections below are folded in).


1. Purpose

Turn the five open foundational-schema domains into decided positions so the six downstream domains (Identity, Ratings, Seasons, Divisions/Eligibility, Match-Formats, Tournaments) converge on one shape instead of forking. Concretely this doc:

  1. Selects the libraries we adopt — with corrected versions/licenses/compatibility notes from the adversarial verdicts (§2).
  2. States the decided position per area — decision + concrete DDL + hex-layer wiring + what it replaces (§3).
  3. Resolves the 4 cross-cutting decisions the audit left open (§4).
  4. Sequences the work into dependency-ordered, independently-shippable steps, flagged SHIP-NOW vs HELD, tied to Phase B / Phase C (§5).
  5. Names the genuine product/legal calls that remain (§6).

2. Library selection table

Every library below was version/license/compatibility-checked against the live npm registry in the source research and corrected by an adversarial verdict. The corrections are folded in (struck picks dropped; download/license/compat errors fixed).

Adopted

LibraryVersionWhat it solvesWhy (popularity / maintenance / license / fit)Runs whereReplaces
iso-3166^4.4.0Canonical ISO-3166 code data: alpha2↔alpha3↔numeric maps + iso31662 subdivisions (verified to contain exactly 17 KR-11…KR-50, 1:1 with our SIDO keys)MIT, 0 deps, ~49k dl/wk (verdict-corrected from "57k"), maintained by a top-tier author, pure-JS. ESM-only (verdict caveat — fine for Deno + modern Metro/Hermes, no CommonJS require).Client RN (Hermes) + Deno edgeNothing today — backs the new SIDO↔ISO derivation helper + the nationality CHECK/validation
country-flag-icons^1.6.17Country flag rendering via raw SVG markup (/string/3x2 export)MIT, 0 deps, very fresh (May 2026), high adoption (224 dependents). The SvgXml render path is already proven in-repo (packages/ui/src/logo.tsx).Client RN (via react-native-svg SvgXml) + web (<svg>)Emoji flags (broken on stock Android — fall back to "KR" letters)
openskill4.1.1SPEC + TEST ORACLE ONLY for a future Bayesian rating engine (μ/σ, team-native 2v2, predictWin/predictDraw). Re-implemented in plpgsql; the JS pkg pins the SQL output in a Jest test.MIT, team-native (the only mature lib that is), pure-JS deps (ramda/gaussian/sort-unwind — no native). dev-dependency, never runtime. Adoption is low (~3k dl/mo, verdict-corrected from "16.8k") but it is the best-fit licensed team-capable system.dev-only (Jest oracle); math ported to PostgresNothing — joins only if/when reliability/decay must enter the rating math (HELD)

Adopted as a standard to copy, not a dependency

StandardSourceWhat it solvesWhy copy, not install
TODS matchUpFormat code grammar (e.g. SET3-S:6/TB7, SET3-S:6/TB7-F:TB10)CourtHive / ITF TODS specHuman+machine-readable match-format serialization → ITF alignment + future UTR/WTN interopThe standalone pkg tods-matchup-format-code was UNPUBLISHED from npm 2026-05-07 and its repo 404s (verdict correction — the source rec wrongly listed it as "low maintenance / installable"). The grammar lives on only in docs + the heavyweight factory. We implement parse/validate ourselves in domain/rules/ (the codebase's proven pure-rule pattern).
SIDO↔ISO-3166-2:KR map (17 entries, KR-11…KR-50)derived from iso-3166Region machine-encoding seam for future international expansion17-line permanent Record in config/regions.ts; an arch-test asserts it covers exactly iso-3166's KR-* set (drift guard). Inlining avoids parsing iso31662 in a hot path.
UTR per-set game-weightingUTR FAQ (verified)Set-aware ELO game_pct: set tiebreak = 1 game, 3rd-set 10-pt match tiebreak = 2 games, rating = % total games wonPure math, must run inside submit_match_score plpgsql (no lib runs in Postgres). Codified per-format in the HELD scoring engine.

Evaluated but NOT adopting

LibraryWhy not
iso-3166-2 (olahol)Unmaintained 9 years; redundant with iso-3166's iso31662. (Verdict correction: it is ISC-licensed, not "no license" as the source rec claimed — but the staleness/redundancy disqualifier stands.)
world-countries (mledoze)ODbL-1.0 share-alike database license — awkward for a closed-source commercial app. iso-3166 (MIT) covers codes.
i18n-iso-countriesNo subdivision data; only localized country names. OPTIONAL companion only if the nationality picker later needs Korean country labels. (Verdict: the source rec's "alpha-1 only" wording is wrong terminology — it supports alpha-2/3/numeric country codes.)
All emoji-flag libsStock Android renders regional-indicator flags as "KR" letters. SVG is the only robust universal path.
glicko2 / glicko2-lite / glicko-two1v1 only — cannot model doubles (the dominant Korean format) without the "naive workaround" their own docs warn against.
ts-trueskillTeam-capable but Microsoft patent/research-license cloud (the reason OpenSkill exists) + pulls heavy mathjs@13 (Apache-2.0 bundling LGPL CSparse — heavier license surface than implied).
glicko2.tsGPL-3.0-or-later — copyleft-incompatible with a closed-source app. (Verify the live license field before quoting externally — lowest-confidence claim.)
rrule (2.8.1)Mature but maintenance-only (last publish 2023-11, 211 open issues centered on the exact Date/DST ambiguity we deliberately engineered around). 30–50KB to replace ~25 lines of in-tree recurrence. HELD as the named EXDATE/holiday-skip upgrade path.
@date-fns/tz / @js-temporal/polyfill / luxon / dayjs-tzEvery IANA-TZ lib hits the Hermes-doesn't-expose-TZ-to-Intl wall (silently treats timeZone as UTC) → needs a heavy FormatJS polyfill to solve a problem a single-timezone (Korea-only) product does not have. @js-temporal/polyfill is the named future target if international expansion ever lands.
json-rules-engine / json-logic-js / json-logic-engine / CASLBuilt for user-authored, open-ended rule sets — the opposite of our closed 6-dimension/4-operator eligibility gate. None run in Postgres (where the enforcement seam lives), so a lib would be a second, divergent impl. Custom ~60-line evaluator matches the bracket/swiss/waitlist.rules.ts precedent.
tods-competition-factory (5.2.1, MIT, 0 runtime deps)A tournament-document factory, not a set-by-set scoring validator — its value is to Domain 5 (Tournaments), not match scoring. Too heavy to pull in for set-validation.
ragibkl/tennis-scoringToy kata-grade, no license, dead since 2020.

3. Decided positions (per area)

3.1 Geography & ISO codes — additive ISO encoding; keep english SIDO keys

DECISION. Adopt ISO-3166 as the canonical machine encoding additively — do not rip out the 17 english SIDO keys (stable, language-invariant, deep-link-safe, satisfy no-localized-option-value). Region unification is HELD (cross-domain workstream, not a now-migration — adding an iso_region_code column now manufactures the exact "third encoding per table" drift the audit warns about). Two things ship: nationality (flat, now) and a pure SIDO↔ISO derivation helper (no migration).

Schema (SHIP-NOW — nationality, Phase B):

sql
ALTER TABLE public.profiles
  ADD COLUMN IF NOT EXISTS nationality_code CHAR(3) NOT NULL DEFAULT 'KOR'
    CHECK (nationality_code ~ '^[A-Z]{3}$');
COMMENT ON COLUMN public.profiles.nationality_code IS
  'ISO 3166-1 alpha-3 nationality. Coarse, non-sensitive (not visibility-gated).';
-- re-derive get_public_profile_view from the CURRENT 00221 body
-- (CREATE OR REPLACE, search_path='', REVOKE PUBLIC + GRANT authenticated)
-- to add nationality_code as an always-public field.

Alpha-3 (not alpha-2): the tennis-standards ecosystem (TODS/ITF nationalityCode, UTR) uses alpha-3, and KOR reads correctly to Korean users (IOC≡ISO for Korea).

Schema (NEAR-TERM, INDEPENDENT data fix — public_courts region normalization):

sql
-- normalize Korean LABELS ('서울') -> english SIDO keys ('seoul') to reconcile
-- with court_venues. Idempotent CASE UPDATE. DO NOT run a SIDO->ISO backfill here.
UPDATE public.public_courts SET region = 'seoul' WHERE region = '서울';
-- ... 16 more sido ...

⚠️ Verdict correction (decouple from the ISO/nationality work): this is a pre-existing data-quality bug, independent of nationality — it must NOT gate the nationality ship. Also: normalizing the stored public_courts.region column does not require touching REGION_PREFIXES (the source rec conflated them). REGION_PREFIXES is Korean address-prefix parsing, legitimately Korean and unrelated to the stored column — but still grep court-directory consumers before flipping.

Wiring (hex):

  • Config (config/regions.ts) — add SIDO_TO_ISO_3166_2: Record<SidoKey, 'KR-11'|…> + inverse + sidoToIsoRegion()/isoRegionToSido() (pure, no I/O). Arch-test asserts the 17 keys map exactly onto iso-3166's KR-* set.
  • Entity (profile.entity.ts) — add nationalityCode: string (default 'KOR') + nationalityCodeSchema = z.string().regex(/^[A-Z]{3}$/). Region/regionSchema (17 english keys) stay the canonical region type, unchanged.
  • Mapper (profile.mapper.ts)nationalityCode: row.nationality_code ?? 'KOR' (read) + conditional write. Region mapping unchanged.
  • UI<FlagIcon code size /> in @twomore/ui (SvgXml from country-flag-icons markup; web uses the React component). <NationalityChip> (flag + label) in @twomore/app (domain-aware, like tier/trust badges). Surfaces on profile hero, public-profile, leaderboard RankRow/PlayerIdentityChip. Region UI untouched.

What it replaces. Nothing in the region stack is replaced now (ISO helper is purely additive). The public_courts fix removes one encoding divergence (Korean labels), moving from three+ encodings toward two (english key + JSONB).


3.2 Rating engine — keep custom plpgsql engine; resolve the half-shipped Glicko

DECISION. KEEP the custom Elo-v2 engine. Do NOT adopt a JS rating library as the runtime engine. The decisive constraint is architectural: rating math lives inside private.submit_match_score (plpgsql) as the atomic source of truth (the same transaction writing matches/elo_history/club_elo_ratings/player_matchups). No JS lib runs in Postgres; moving the engine to edge/client breaks atomicity and re-opens the offline-replay/idempotency problems the codebase closed. A lib would be a reference impl we hand-port anyway.

Resolve the "Glicko in name only" finding by committing to a model, not leaving the worst-of-both state. Today singles_rd/doubles_rd/rating_confidence exist but RD never enters the expected-score or K math (confirmed at 00084:266, 00085:303); RD only shrinks ×0.92 and grows via a dead, never-scheduled, prod-403'ing grow-rd cron.

Schema (SHIP-NOW):

sql
-- (1) HONESTY FIX — do NOT silently activate dead Glicko. Doc-as-inert (recommended):
COMMENT ON COLUMN public.profiles.singles_rd IS
  'INERT: stored but never enters E()/K math. Reserved for a future OpenSkill/Glicko migration.';
COMMENT ON COLUMN public.profiles.doubles_rd IS 'INERT — see singles_rd.';
-- + delete only the never-scheduled grow-rd edge fn + its prod-403 guard.

-- (2) Constrain display_tier to the live 5-tier set (Skill-Tiers domain).
-- ⚠️ VERIFY the current default first (verdict: grep found no 'bronze' DEFAULT in 00045/00066 —
--    the column may already be defaultless; only DROP DEFAULT if one exists).
ALTER TABLE public.profiles ADD CONSTRAINT profiles_display_tier_chk
  CHECK (display_tier IS NULL OR display_tier IN ('bronze','silver','gold','master','grandmaster'));

Reliability model = 3-state pill from match count (now). No raw RD/σ number until σ actually enters the math.

Wiring (hex):

  • Runtime engine stays private.submit_match_score plpgsql. Client mirror domain/rules/elo.rules.ts (calculateEloV2, divisor 400) stays byte-equivalent for optimistic preview.
  • Reliability pill — pure getReliability(matchesPlayed): 'provisional'|'developing'|'established' (<5 / 5–19 / 20+) in domain/rules/. No migration; threads via existing eloMatchesPlayed on Profile. i18n 신규 / 측정 중 / 안정.
  • Tier-function collapse — delete the drifting tier functions and re-export one. ⚠️ Verdict correction: there are THREE, not twogetEloTier (profile.entity.ts:17), getTierFromElo (tier.config.ts:129), and getEloTierFromRating (elo.rules.ts:339). Collapse all three onto getTierFromElo.
  • NTRP/UTR strings — re-derive the 3 hardcoded i18n strings from eloToNtrp/eloToUtr; label "대략적인 실력 (approximate, no official conversion)."

What it replaces. ELO_TIERS/getEloTier/getEloTierFromRating (dual/triple-literal drift), the dead grow-rd + decay_confidence_for_long_inactive + get_inactive_players_for_rd_growth, the hardcoded NTRP/UTR strings.

HELD (Phase 11 / real decay demand). If reliability/decay must enter the math: add openskill@4.1.1 as a devDependency oracle, write domain/rules/__tests__/openskill-oracle.test.ts (identical inputs through JS + plpgsql, assert equality), then port OpenSkill Weng-Lin Bradley-Terry (pure logistic — no special functions) into a submit_match_score v3. ⚠️ Avoid Thurstone-Mosteller / the Gaussian-CDF path: the source rec claimed "Postgres covers it via built-in erf()" but erf() landed in PG16 and Supabase defaults to PG15 (verdict correction) — erf() is unavailable on our likely runtime. Bradley-Terry sidesteps this entirely. The normalized player_ratings(sport, discipline, namespace, algorithm, sigma, …) table is speculative generality — do not build before a consumer.


3.3 Seasons & recurrence — promote seasons (held); keep native recurrence + fix expansion; keep KST-implicit time

Three decisions with different triggers.

(A) Seasons table — PROMOTE, but HELD until Phase 11 / first non-quarter consumer. Today "seasons" is a stringly-typed "YYYY-QN" convention across three incompatible models (season.entity.ts calendar ranges with zero consumers; season-record.entity.ts quarter strings; season_records rows keyed on the free-text quarter). A real seasons table is the join point for season-scoped membership, tournament draw scope, and recurrence anchoring — design now, migrate on the first real consumer.

sql
-- HELD — do not apply until a real consumer (league season / configurable season) exists.
CREATE TABLE public.seasons (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  club_id    UUID REFERENCES public.clubs(id) ON DELETE CASCADE,  -- NULL = global/system
  slug       TEXT NOT NULL,                 -- '2026-Q1' (back-compat) | '2026-spring'
  name       TEXT NOT NULL,
  kind       TEXT NOT NULL DEFAULT 'quarter'
               CHECK (kind IN ('quarter','calendar','custom','league')),
  start_date DATE NOT NULL,
  end_date   DATE NOT NULL,
  is_current BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  CHECK (end_date >= start_date),
  UNIQUE (club_id, slug)
);
CREATE UNIQUE INDEX idx_seasons_one_current_per_club
  ON public.seasons (club_id) WHERE is_current = true;
ALTER TABLE public.seasons ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON public.seasons TO authenticated;   -- writes via SECDEF RPC only
-- then: season_records.season_id UUID REFERENCES seasons(id) (keep TEXT `season` during transition),
--       backfill by matching 'YYYY-QN' -> seasons.slug.

Ship-now cleanup (code-only): keep getCurrentQuarterSeason as the quarter-row seeder; delete the orphan spring/summer/fall/winter Season shape + getRecentSeasons. ⚠️ Verdict correction: these are re-exported from the domain/index.ts public barrel — the removal is a public-API edit, still safe (no downstream importer), not just a private-file delete.

(B) Recurrence — keep the native model; do NOT adopt rrule; fix WHERE expansion runs. The bug isn't the model (weekly/biweekly/monthly + dayOfWeek covers 100% of Korean club reality), it's that use-create-session.ts eagerly pre-creates N session rows in a client loop, ignoring the recurrences table (00038) that already exists — so series can't be edited/cancelled, can't go "forever" past the eager cap, and a client loop of N inserts is fragile. Adopt the existing recurrences table as source-of-truth + a Deno cron rolling-window expander.

sql
-- SHIP-NOW (when recurring-sessions is prioritized — this is a FEATURE, not a cleanup):
ALTER TABLE public.recurrences
  ADD COLUMN IF NOT EXISTS anchor_date            DATE,  -- first occurrence (expansion seed)
  ADD COLUMN IF NOT EXISTS until_date             DATE,  -- optional hard end (NULL = rolling)
  ADD COLUMN IF NOT EXISTS last_materialized_date DATE;  -- cron bookmark
-- + sessions partial-unique (recurrence_id, date) for idempotent inserts.

-- HELD (only if EXDATE/holiday-skip/multi-day/BYSETPOS ever needed): adopt rrule@2.8.1
--   ADD COLUMN rrule_string TEXT;  ADD COLUMN exdates DATE[];   -- expand server-side

⚠️ Verdict correction — the source rec's "fix the 00038/00039 USING (true) RLS hole, land it today regardless" is PHANTOM WORK. Migration 00039 already replaced both policies with club_members-scoped predicates (SELECT = active member; manage = owner/admin/match_director). There is no open RLS hole. Strike that "urgent ship-now" item entirely.

Wiring: generateRecurrenceDates stays the single shared expansion rule (used by both client preview + the new expand-recurrences Deno cron that materializes today → today+8w, idempotent, linked via sessions.recurrence_id). use-create-session.ts + host-pickup-game.usecase.ts stop looping: insert ONE recurrences row + the first linked session; cron does the rest. recurrenceMaxOccurrences plan caps (2/4/8/12/52) map onto recurrences.max_occurrences.

(C) Timezone — NO change. Keep KST-implicit (device-local + noon-anchor). The decisive fact: Hermes does not expose IANA TZ data to Intl (silently treats timeZone as UTC); any TZ lib needs a heavy FormatJS polyfill to solve a problem a single-TZ market doesn't have. Keep date DATE + start_time TIME (wall-clock) + date-utils.ts. The only forward seam: on international, add timezone TEXT DEFAULT 'Asia/Seoul' and shift authority to Postgres timestamptz. HELD target if ever needed: @js-temporal/polyfill.


3.4 Divisions + eligibility-gate — Skill-Tiers owns a 4-axis divisions table; eligibility is a custom typed predicate

DECISION (divisions). Skill-Tiers owns a single divisions lookup table; all six domains FK into it. Model as four orthogonal axes, grounded in ITF/USTA taxonomy (HELD — no consumer until Phase 11):

sql
-- HELD — DDL hint, do NOT migrate now.
CREATE TABLE public.divisions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  discipline      TEXT NOT NULL CHECK (discipline IN ('singles','doubles','mixed_doubles')),
  gender_category TEXT NOT NULL CHECK (gender_category IN ('men','women','mixed','open')),
  age_bracket     TEXT     CHECK (age_bracket IS NULL OR age_bracket ~ '^(u1[2468]|o[3-9][05])$'),
  adaptive_class  TEXT NOT NULL DEFAULT 'none' CHECK (adaptive_class IN ('none','open','quad')),
  label_key       TEXT NOT NULL,   -- i18n key; Korean labels never in DB
  sort_order      INT  NOT NULL DEFAULT 0,
  UNIQUE (discipline, gender_category, age_bracket, adaptive_class)
);
-- RLS SELECT to authenticated (reference data); NO write grant (service_role only).

⚠️ Verdict corrections: (1) discipline does not match the existing session_format enum — that enum is doubles|singles|singles_doubles|mixed_doubles (it includes singles_doubles). The new axis is a deliberate divergence; name it as such and reconcile singles_doubles. (2) There is no existing rating_discipline enum — drop that "matches existing enum" framing; it's aspirational. (3) age_bracket is a threshold key derived from birth_year at eligibility time — never a stored per-player age. (4) adaptive_class='quad' membership is PIPA §23 sensitive health data — the divisions row only names the bracket; player→quad mapping is a consent-gated table off the data API (HELD, legally gated).

DECISION (eligibility gate). Custom typed {dimension, op, value}[] predicate evaluated by a pure domain/rules/ function — NOT a rules-engine library. Closed vocabulary (≤6 dimensions: tier, age, gender, division, nationality, trust_tier; ~4 ops: gte/lte/eq/in). Must evaluate identically in Hermes (client pre-check) + Deno + Postgres (the enforcement seam is a BEFORE INSERT/UPDATE trigger on rsvps, per the 00133 cooldown precedent — confirmed, the RSVP write is a bare .upsert(), not an RPC). No JS lib runs in plpgsql, so a lib would be a divergent second impl.

ts
// domain/entities/eligibility.entity.ts  (SHIP-NOW: schema + tier dimension only)
export const ELIGIBILITY_DIMENSIONS = ['tier','age','gender','division','nationality','trust_tier'] as const;
export const ELIGIBILITY_OPS = ['gte','lte','eq','in'] as const;
export const eligibilityPredicateSchema = z.object({
  dimension: z.enum(ELIGIBILITY_DIMENSIONS),
  op:        z.enum(ELIGIBILITY_OPS),
  value:     z.union([z.string(), z.number(), z.array(z.string())]),
});
// domain/rules/eligibility.rules.ts  (pure, tested; tier via TIER_ORDER.indexOf)
export function evaluateEligibility(gates, ctx): { ok: boolean; failed?: EligibilityPredicate } { … }

Critical finding (drives the ship-now slice): the tier gate is NOT enforced server-side today at allmin_tier/max_tier (00093) are display/discovery-only columns. The ship-now slice both adds the missing tier enforcement (a private.rsvps_enforce_eligibility() trigger raising P0001 HINT='eligibility_tier') and establishes the predicate seam.

⚠️ Verdict correction — leaner ship-now: there is no existing server tier check to refactor, and the client check today is display-only. Ship the pure evaluateEligibility() + Zod schema + tests (cheap, establishes the shape), but defer wiring it into a live client CTA until the server trigger exists — otherwise you ship a client gate with no server backstop.

Storage (HELD): sessions.eligibility_gates JSONB + clubs.eligibility_gates (inheritance), validated by eligibilityPredicateSchema on read (Protocol K safeParse). Generalizes/eventually replaces the bespoke min_tier/max_tier columns.


3.5 Match formats & scoring — kill the lying selector now; full multi-set engine HELD + custom

DECISION. Three concrete calls (Phase C prep + held engine):

(1) SHIP-NOW — kill the lying selector, KEEP the dead match_sets stack. The 세트 수 PillNav (match-rules-editor.tsx:120-132) writes 1_set/2_of_3/3_of_5 into match_rules.format and nothing reads it — the scorecard + the 00147 ELO RPC both score exactly one game-pair. A user picking "5판 3선승" gets a single-set scorecard (the audit's "lying UI"). Hide the selector (multiSetEnabled = falseformat always '1_set') rather than delete the match_sets (00056) stack — the stack (port/adapter/mapper/registry/keys/entity) is correctly-shaped, RLS'd, GRANT'd, and is the exact foundation Phase C needs; re-adding it later is more work than a // HELD until Phase C banner. This diverges from the audit's "delete the stack" option — wire-later beats delete because only the consumer is missing.

⚠️ Before hiding: grep every reader of match_rules.format (score-edit-sheet.tsx, effective-match-rules.ts, the 00147 RPC, share/recap) to confirm zero readers/validation paths depend on it.

(2) HELD (Phase C) — full design as docs/specifications/match-formats.md, NOT a pending SQL file:

sql
-- HELD. Explicit set columns — NEVER overload team*_score to mean sets.
ALTER TABLE public.matches
  ADD COLUMN team1_sets_won SMALLINT,
  ADD COLUMN team2_sets_won SMALLINT,
  ADD COLUMN format_id UUID REFERENCES public.match_format(id);
ALTER TABLE public.match_sets
  ADD COLUMN is_match_tiebreak BOOLEAN,
  ADD COLUMN set_kind TEXT;          -- mark the 10-pt decider for UTR weighting
-- match_format registry (rows, NOT an enum — ALTER TYPE ADD VALUE is irreversible):
--   id, tods_code TEXT, spec JSONB (MatchFormatSpec), discipline, label_key.
--   RLS + GRANT SELECT TO authenticated; no write grant.
--   seed: korean_single_set 'SET1-S:6/TB7', itf_tiebreak_bo3 'SET3-S:6/TB7',
--         match_tiebreak_in_lieu_bo3 'SET3-S:6/TB7-F:TB10', grand_slam_bo5_10pt_final,
--         pro_set_8, fast4_bo3.
  • team*_score stays "games in the displayed set" and is never overloaded to mean sets. match_sets rows hold per-set team{1,2}_games + tiebreak_team{1,2} (already exist). team*_sets_won is the best-of-N result.
  • MatchRules (match-rules.entity.ts) promotes to a composable MatchFormatSpec (Zod): { tods_code, bestOf, set:{games,winBy,tiebreak}, finalSet:{kind,target}, noAd }; single-set becomes the degenerate case.

(3) Engine = CUSTOM (domain/rules/scoring.rules.ts, pure + tested): parseMatchFormatCode(tods) → MatchFormatSpec, isSetComplete, isMatchComplete → winner|null, deuce/no-ad validation — replacing the ad-hoc parseTrigger/isTiebreakTriggered heuristics. Adopt the TODS grammar (copy SET3-S:6/TB7), implement parse/validate ourselves (the pkg is unpublished; the factory is the wrong tool). Set-aware ELO game_pct (UTR weighting) is re-derived server-side in submit_match_score (no lib in plpgsql): sum games across match_sets, set-TB = 1 game, match-TB-in-lieu = 2 games, game_pct = winner_games / total_games; new p_sets JSONB param. A useMatchSets(matchId) hook (currently absent — matchSetKeys exist but no hook) feeds a per-set scorecard.

What it replaces. The lying selector; the ad-hoc scorecard heuristics; the single-set v_game_pct := GREATEST(...) / v_total_games (00147:441).

⚠️ The ELO RPC rewrite (HELD step) is the ONLY change in the whole plan that alters existing ratings. Everything else is additive. Existing single-set rows are unaffected (best-of-1 winner_games/total_games is identical under old/new math) — gate the new path to multi-set only, with an explicit invariant test. Requires ELO-recalibration sign-off + DROP the old RPC signature (the CREATE OR REPLACE footgun).


4. The 4 cross-cutting decisions — RESOLVED

#Cross-cutting questionDECIDED position
(i)Region unification — one canonical region encoding?DECIDED: keep the 17 english SIDO keys as the canonical internal value; add an ISO-3166 derivation helper only (no column). Region unification is a HELD cross-domain workstream — an iso_region_code column is deferred to international expansion (adding it now creates the third-encoding drift the audit flags). The one near-term move is the independent public_courts Korean-label→english-key data fix.
(ii)Divisions ownership — who defines the division taxonomy?DECIDED: Skill-Tiers owns one divisions lookup table; Identity/Ratings/Match-Formats/Tournaments/Player-Categories all FK into it. Modeled as 4 orthogonal axes (discipline, gender_category, age_bracket, adaptive_class). No private bracket/adaptive division anywhere. HELD until Phase 11 — the value now is reserving ownership + the 4-axis shape so downstream domains converge.
(iii)Seasons table — promote the stringly-typed "YYYY-QN" to a real table?DECIDED: yes, promote — but HELD until the first non-quarter consumer (Phase 11 leagues / configurable season). Design the table now (§3.3 DDL) as the join point for season-scoped membership + tournament scope + recurrence anchoring; keep the computed quarter string until a consumer lands; ship-now cleanup deletes the orphan calendar-Season shape.
(iv)Eligibility-gate shape — rules-engine library vs custom predicate?DECIDED: custom typed {dimension, op, value}[] predicate, evaluated by a pure domain/rules/eligibility.rules.ts function + a mirrored plpgsql BEFORE INSERT/UPDATE trigger on rsvps. Closed 6-dimension/4-operator vocabulary; must run in Hermes + Deno + Postgres; no JS lib runs in plpgsql. Ship-now: schema + tier dimension + tests + the missing server-side tier-enforcement trigger.

5. Ordered implementation plan

Dependency-ordered. Each numbered step = its own migration/commit. SHIP-NOW = ready against its motivating feature; HELD = docs/specifications/ target until its trigger lands. Phase tags map to the ATP epic (B = profile affiliations, C = flexible multi-set scoring).

SHIP-NOW (independently shippable, OTA-able unless noted)

  1. [SHIP-NOW · code-only] Collapse the 3 drifting tier functions → re-export getTierFromElo for getEloTier + getEloTierFromRating. Highest-value, zero-risk, no migration. (Ratings)
  2. [SHIP-NOW · code-only] Reliability pill — add getReliability(matchesPlayed) to domain/rules/; render the 3-state pill on hero-card + public-profile (both already receive matchesPlayed). i18n 신규/측정 중/안정. (Ratings)
  3. [SHIP-NOW · code-only] Re-derive NTRP/UTR strings from eloToNtrp/eloToUtr; label "approximate." (Ratings)
  4. [SHIP-NOW · UI-only] Hide the lying 세트 수 selector (multiSetEnabled = false) + add // HELD until Phase C banner to the match_sets port/adapter. Grep match_rules.format readers first. (Match-Formats, Phase C prep)
  5. [SHIP-NOW · code-only] Eligibility predicate shapeeligibility.entity.ts (Zod) + eligibility.rules.ts (tier dimension via TIER_ORDER) + unit tests. Do NOT wire into a live client CTA yet (defer until step 9's server trigger). (Divisions/Eligibility)
  6. [SHIP-NOW · deps + UI] Geography deps + <FlagIcon>yarn workspace @twomore/ui add country-flag-icons + yarn workspace @twomore/app add iso-3166; build <FlagIcon> (SvgXml). Add SIDO_TO_ISO_3166_2 map + arch-test (drift guard). No native build (react-native-svg@15.15.3 already present) → OTA-safe. (Geography, Phase B)
  7. [SHIP-NOW · migration] profiles.nationality_code (Phase B) — add column + CHECK; re-derive get_public_profile_view from the current 00221 body; regenerate generated.types.ts; entity + schema + mapper; <NationalityChip> + nationality selector in edit-profile (default 대한민국). (Geography/Identity, Phase B)
  8. [SHIP-NOW · migration] Glicko honesty fix + display_tier CHECK — doc-as-inert COMMENTs on singles_rd/doubles_rd; delete the never-scheduled grow-rd edge fn; add display_tier CHECK (verify the current default before any DROP DEFAULT). (Ratings)
  9. [SHIP-NOW · migration · ⚠️ PRODUCT CALL] rsvps_enforce_eligibility() trigger (tier dimension)BEFORE INSERT/UPDATE, reads inherited min_tier/max_tier, resolves tier from elo_rating, raises P0001 HINT='eligibility_tier'; SECDEF search_path='' + REVOKE PUBLIC. This starts ENFORCING a previously-cosmetic gate — needs a "grandfather already-confirmed RSVPs" decision (mirror 00133's guard). After this lands, step 5's client pre-check can wire into the CTA. (Divisions/Eligibility)
  10. [SHIP-NOW · migration · independent data fix] public_courts region normalization — idempotent Korean-label→english-key CASE UPDATE. ⚠️ Decoupled from nationality; grep court-directory consumers; do NOT touch REGION_PREFIXES. (Geography — pre-existing bug, not a Phase-B blocker)

Note: the audit/source-rec's "fix the recurrence USING (true) RLS hole today" item is REMOVED — migration 00039 already closed it (verdict correction). There is no urgent RLS item in this plan.

HELD (build only when the trigger lands)

  1. [HELD · feature, not cleanup] Recurrence expansion rework (trigger: recurring-sessions prioritized) — ALTER recurrences (anchor/until/last_materialized) + sessions partial-unique (recurrence_id, date); expand-recurrences Deno cron (⚠️ supabase functions deploy --no-verify-jwt + db push — server-side, NOT OTA); rewrite use-create-session.ts + host-pickup-game.usecase.ts to insert one recurrence row + first session; add series edit/cancel. (Seasons/Recurrence)
  2. [HELD] Seasons table (trigger: Phase 11 leagues / configurable season) — migration (§3.3 DDL) + season_records.season_id FK + backfill; delete orphan Season shape (public-barrel edit); SeasonRepositoryPort + SECDEF open/close_season; re-key use-season-standings to seasonId. (Seasons)
  3. [HELD] Match-formats engine (Phase C, trigger: KR club plays best-of-3) — migration (registry rows + team*_sets_won + match_sets.set_kind); MatchFormatSpec Zod promotion; scoring.rules.ts (custom, TODS grammar); ⚠️ set-aware submit_match_score rewrite (ELO recalibration sign-off + DROP old signature + multi-set-only gating + invariant test); useMatchSets hook + per-set scorecard; un-hide the selector. (Match-Formats + Ratings)
  4. [HELD · ⚠️ LEGAL] OpenSkill rating migration (trigger: Phase 11 / real decay demand) — add openskill@4.1.1 devDep oracle + equality test; port Weng-Lin Bradley-Terry (avoid erf/PG15) into submit_match_score v3; live rating-behavior change for every player → re-seed/recompute decision (replay vs snapshot-and-restart) + explanatory UI. Normalized player_ratings table only if a multi-algorithm consumer exists. (Ratings)
  5. [HELD · ⚠️ LEGAL] Divisions table + multi-dimension eligibility (trigger: Phase 11) — divisions 4-axis table; sessions.eligibility_gates JSONB + inheritance; age/gender/division predicate branches + plpgsql evaluator mirror; FK wiring from tournaments/ratings/identity. adaptive_class='quad' membership = consent-gated PIPA §23 table off the data API + purpose_catalog row + policy_versions bump. (Divisions/Eligibility)
  6. [HELD] rrule + @js-temporal/polyfill — named upgrade paths in docs/specifications/ for EXDATE/holiday-skip recurrence and international multi-timezone (authority shifts to Postgres timestamptz). No code until those features land.

6. Decisions still needing the product owner

  1. [Geography/Identity — Phase B] Nationality is non-sensitive + not consent-gated. Confirm nationality_code NOT NULL DEFAULT 'KOR' ships as an always-public, non-visibility-gated field (audit Open Decision #1 recommends yes). (Blocks step 7.)
  2. [Divisions/Eligibility] Tier-gate enforcement is a one-way behavioral change. Confirm the missing server-side tier gate (step 9) should start enforcing, and decide whether existing under/over-tier RSVPs are grandfathered (recommend: mirror 00133's "already-confirmed passes"). (Blocks step 9.)
  3. [Ratings] ELO recalibration sign-off for the set-aware game_pct rewrite (step 13) and any future OpenSkill migration (step 14) — these alter live ratings and need a re-seed/recompute decision + explanatory UI. (Blocks steps 13/14.)
  4. [Match-Formats — Phase C] Best-of-3 rollout scope — ship to all Korean clubs (≈95% single-set near-term) or gate to leagues only? (Gates un-hiding the selector in step 13.)
  5. [Divisions/Eligibility — LEGAL] Para-tennis (adaptive_class='quad') membership is PIPA §23 sensitive health data — requires counsel sign-off on the consent-gated off-API table before any para product. (Blocks step 15.)
  6. [PIPA — junior floor / inclusive gender] Confirm the under-14 birth-year floor interacts correctly with age_bracket derivation, and decide whether the gender_category event axis (men/women/mixed/open) needs to coexist with an inclusive player-attribute gender beyond the current male|female (profiles.gender). (Product/legal input for step 15.)
  7. [Seasons] Club season = fixed calendar quarter forever, or admin-configurable? Decides whether kind='quarter' stays the only path or whether calendar/custom/league ship. (Shapes step 12.)

7. Sources

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