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:
- Selects the libraries we adopt — with corrected versions/licenses/compatibility notes from the adversarial verdicts (§2).
- States the decided position per area — decision + concrete DDL + hex-layer wiring + what it replaces (§3).
- Resolves the 4 cross-cutting decisions the audit left open (§4).
- Sequences the work into dependency-ordered, independently-shippable steps, flagged SHIP-NOW vs HELD, tied to Phase B / Phase C (§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
| Library | Version | What it solves | Why (popularity / maintenance / license / fit) | Runs where | Replaces |
|---|---|---|---|---|---|
| iso-3166 | ^4.4.0 | Canonical 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 edge | Nothing today — backs the new SIDO↔ISO derivation helper + the nationality CHECK/validation |
| country-flag-icons | ^1.6.17 | Country 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) |
| openskill | 4.1.1 | SPEC + 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 Postgres | Nothing — joins only if/when reliability/decay must enter the rating math (HELD) |
Adopted as a standard to copy, not a dependency
| Standard | Source | What it solves | Why copy, not install |
|---|---|---|---|
TODS matchUpFormat code grammar (e.g. SET3-S:6/TB7, SET3-S:6/TB7-F:TB10) | CourtHive / ITF TODS spec | Human+machine-readable match-format serialization → ITF alignment + future UTR/WTN interop | The 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-3166 | Region machine-encoding seam for future international expansion | 17-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-weighting | UTR FAQ (verified) | Set-aware ELO game_pct: set tiebreak = 1 game, 3rd-set 10-pt match tiebreak = 2 games, rating = % total games won | Pure 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
| Library | Why 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-countries | No 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 libs | Stock Android renders regional-indicator flags as "KR" letters. SVG is the only robust universal path. |
glicko2 / glicko2-lite / glicko-two | 1v1 only — cannot model doubles (the dominant Korean format) without the "naive workaround" their own docs warn against. |
ts-trueskill | Team-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.ts | GPL-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-tz | Every 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 / CASL | Built 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-scoring | Toy 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):
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):
-- 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) — addSIDO_TO_ISO_3166_2: Record<SidoKey, 'KR-11'|…>+ inverse +sidoToIsoRegion()/isoRegionToSido()(pure, no I/O). Arch-test asserts the 17 keys map exactly ontoiso-3166'sKR-*set. - Entity (
profile.entity.ts) — addnationalityCode: 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(SvgXmlfromcountry-flag-iconsmarkup; web uses the React component).<NationalityChip>(flag + label) in@twomore/app(domain-aware, like tier/trust badges). Surfaces on profile hero, public-profile, leaderboardRankRow/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):
-- (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_scoreplpgsql. Client mirrordomain/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+) indomain/rules/. No migration; threads via existingeloMatchesPlayedonProfile. i18n신규 / 측정 중 / 안정. - Tier-function collapse — delete the drifting tier functions and re-export one. ⚠️ Verdict correction: there are THREE, not two —
getEloTier(profile.entity.ts:17),getTierFromElo(tier.config.ts:129), andgetEloTierFromRating(elo.rules.ts:339). Collapse all three ontogetTierFromElo. - 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.
-- 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.
-- 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):
-- 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.
// 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 all — min_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 = false → format 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:
-- 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*_scorestays "games in the displayed set" and is never overloaded to mean sets.match_setsrows hold per-setteam{1,2}_games+tiebreak_team{1,2}(already exist).team*_sets_wonis the best-of-N result.MatchRules(match-rules.entity.ts) promotes to a composableMatchFormatSpec(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 question | DECIDED 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)
- [SHIP-NOW · code-only] Collapse the 3 drifting tier functions → re-export
getTierFromEloforgetEloTier+getEloTierFromRating. Highest-value, zero-risk, no migration. (Ratings) - [SHIP-NOW · code-only] Reliability pill — add
getReliability(matchesPlayed)todomain/rules/; render the 3-state pill on hero-card + public-profile (both already receivematchesPlayed). i18n신규/측정 중/안정. (Ratings) - [SHIP-NOW · code-only] Re-derive NTRP/UTR strings from
eloToNtrp/eloToUtr; label "approximate." (Ratings) - [SHIP-NOW · UI-only] Hide the lying
세트 수selector (multiSetEnabled = false) + add// HELD until Phase Cbanner to thematch_setsport/adapter. Grepmatch_rules.formatreaders first. (Match-Formats, Phase C prep) - [SHIP-NOW · code-only] Eligibility predicate shape —
eligibility.entity.ts(Zod) +eligibility.rules.ts(tier dimension viaTIER_ORDER) + unit tests. Do NOT wire into a live client CTA yet (defer until step 9's server trigger). (Divisions/Eligibility) - [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). AddSIDO_TO_ISO_3166_2map + arch-test (drift guard). No native build (react-native-svg@15.15.3 already present) → OTA-safe. (Geography, Phase B) - [SHIP-NOW · migration]
profiles.nationality_code(Phase B) — add column + CHECK; re-deriveget_public_profile_viewfrom the current 00221 body; regenerategenerated.types.ts; entity + schema + mapper;<NationalityChip>+ nationality selector in edit-profile (default 대한민국). (Geography/Identity, Phase B) - [SHIP-NOW · migration] Glicko honesty fix +
display_tierCHECK — doc-as-inert COMMENTs onsingles_rd/doubles_rd; delete the never-scheduledgrow-rdedge fn; adddisplay_tierCHECK (verify the current default before any DROP DEFAULT). (Ratings) - [SHIP-NOW · migration · ⚠️ PRODUCT CALL]
rsvps_enforce_eligibility()trigger (tier dimension) —BEFORE INSERT/UPDATE, reads inheritedmin_tier/max_tier, resolves tier fromelo_rating, raisesP0001 HINT='eligibility_tier'; SECDEFsearch_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) - [SHIP-NOW · migration · independent data fix]
public_courtsregion normalization — idempotent Korean-label→english-key CASE UPDATE. ⚠️ Decoupled from nationality; grep court-directory consumers; do NOT touchREGION_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 — migration00039already closed it (verdict correction). There is no urgent RLS item in this plan.
HELD (build only when the trigger lands)
- [HELD · feature, not cleanup] Recurrence expansion rework (trigger: recurring-sessions prioritized) —
ALTER recurrences(anchor/until/last_materialized) + sessions partial-unique(recurrence_id, date);expand-recurrencesDeno cron (⚠️supabase functions deploy --no-verify-jwt+db push— server-side, NOT OTA); rewriteuse-create-session.ts+host-pickup-game.usecase.tsto insert one recurrence row + first session; add series edit/cancel. (Seasons/Recurrence) - [HELD] Seasons table (trigger: Phase 11 leagues / configurable season) — migration (§3.3 DDL) +
season_records.season_idFK + backfill; delete orphanSeasonshape (public-barrel edit);SeasonRepositoryPort+ SECDEFopen/close_season; re-keyuse-season-standingstoseasonId. (Seasons) - [HELD] Match-formats engine (Phase C, trigger: KR club plays best-of-3) — migration (registry rows +
team*_sets_won+match_sets.set_kind);MatchFormatSpecZod promotion;scoring.rules.ts(custom, TODS grammar); ⚠️ set-awaresubmit_match_scorerewrite (ELO recalibration sign-off + DROP old signature + multi-set-only gating + invariant test);useMatchSetshook + per-set scorecard; un-hide the selector. (Match-Formats + Ratings) - [HELD · ⚠️ LEGAL] OpenSkill rating migration (trigger: Phase 11 / real decay demand) — add
openskill@4.1.1devDep oracle + equality test; port Weng-Lin Bradley-Terry (avoid erf/PG15) intosubmit_match_scorev3; live rating-behavior change for every player → re-seed/recompute decision (replay vs snapshot-and-restart) + explanatory UI. Normalizedplayer_ratingstable only if a multi-algorithm consumer exists. (Ratings) - [HELD · ⚠️ LEGAL] Divisions table + multi-dimension eligibility (trigger: Phase 11) —
divisions4-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_catalogrow +policy_versionsbump. (Divisions/Eligibility) - [HELD] rrule +
@js-temporal/polyfill— named upgrade paths indocs/specifications/for EXDATE/holiday-skip recurrence and international multi-timezone (authority shifts to Postgrestimestamptz). No code until those features land.
6. Decisions still needing the product owner
- [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.) - [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.)
- [Ratings] ELO recalibration sign-off for the set-aware
game_pctrewrite (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.) - [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.)
- [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.) - [PIPA — junior floor / inclusive gender] Confirm the under-14 birth-year floor interacts correctly with
age_bracketderivation, and decide whether thegender_categoryevent axis (men/women/mixed/open) needs to coexist with an inclusive player-attribute gender beyond the currentmale|female(profiles.gender). (Product/legal input for step 15.) - [Seasons] Club season = fixed calendar quarter forever, or admin-configurable? Decides whether
kind='quarter'stays the only path or whethercalendar/custom/leagueship. (Shapes step 12.)
7. Sources
- Audit (parent):
docs/architecture/foundational-schema-audit.md— per-domain findings + adversarial verdicts + ATP Phase B/C linkage. - Geography: iso-3166 (npm) · country-flag-icons (npm) · world-countries LICENSE — ODbL-1.0 · ISO 3166-2:KR — Wikipedia · in-repo:
config/regions.ts,profile.entity.ts,profile.mapper.ts,public_courtsmigration 00032,packages/ui/src/logo.tsx(SvgXmlprecedent),apps/mobile/package.json(react-native-svg@15.15.3). - Ratings: openskill (npm) · OpenSkill — arXiv 2401.05451 · glicko2 (npm) · ts-trueskill (npm) · PostgreSQL 16 erf() · Supabase Postgres versions · in-repo:
00084/00085/00147,domain/rules/elo.rules.ts,tier.config.ts,supabase/functions/grow-rd/,docs/specifications/rating-system-v2.md. - Seasons/Recurrence: rrule (npm) · @date-fns/tz (npm) · @js-temporal/polyfill (npm) · Intl DateTimeFormat polyfill — FormatJS · iCalendar RFC 5545 · in-repo:
00038/00039/00065,recurrence.entity.ts,season.entity.ts,season-record.entity.ts,date-utils.ts,use-create-session.ts. - Divisions/Eligibility: json-rules-engine (npm) · json-logic-js (npm) · json-logic-engine (GitHub) · ITF Wheelchair Regulations 2026 · ITF Masters Tour FAQs · in-repo:
00133(trigger precedent),00093(display-only tier columns),tier.config.ts(TIER_ORDER),rsvp.supabase.ts(bare upsert). - Match-Formats: tods-competition-factory (npm) · tods-matchup-format-code — registry metadata (unpublished 2026-05-07) · matchUpFormat Codes — Competition Factory docs · UTR Rating Algorithm FAQ · Tennis scoring system — Wikipedia · in-repo:
00056(match_sets),00147(submit_match_score),match-rules-editor.tsx,score-edit-sheet.tsx,effective-match-rules.ts,match-set.supabase.ts.