diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 10b2aa3..bd02bf6 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -52,7 +52,8 @@
"Bash(npx --yes js-yaml .github/workflows/security.yml)",
"Skill(claude-api)",
"Bash(deno --version)",
- "Bash(git check-ignore *)"
+ "Bash(git check-ignore *)",
+ "Bash(sort -k5 -n -r)"
]
}
}
diff --git a/hardening_migration.sql b/hardening_migration.sql
index e6ca782..6d20150 100644
--- a/hardening_migration.sql
+++ b/hardening_migration.sql
@@ -276,7 +276,7 @@ BEGIN
-- Validate source_type against the CHECK constraint domain.
-- Mirrors migrations/007a_add_source_type.sql so a malformed payload
-- never reaches the insert and never trips a 23514 CHECK error.
- IF p_source_type NOT IN ('manual', 'live_ble', 'imported_csv', 'webhook') THEN
+ IF p_source_type NOT IN ('manual', 'live_ble', 'imported_csv') THEN
RETURN jsonb_build_object('success', false, 'error', 'invalid source_type');
END IF;
diff --git a/migrations/007a_add_source_type.sql b/migrations/007a_add_source_type.sql
index 675c8d7..9c4bcd8 100644
--- a/migrations/007a_add_source_type.sql
+++ b/migrations/007a_add_source_type.sql
@@ -21,8 +21,8 @@
-- back-fill it to align with the new four-value domain.
-- 2. Drops the legacy CHECK constraint installed by mig 019 (which
-- allowed only `'live_ble' | 'manual_staff'`).
--- 3. Installs the new CHECK constraint with the spec's full domain:
--- 'manual' | 'live_ble' | 'imported_csv' | 'webhook'
+-- 3. Installs the new CHECK constraint with the canonical 3-value domain:
+-- 'manual' | 'live_ble' | 'imported_csv'
-- 4. Sets the column DEFAULT to `'manual'` (the safe, conservative default
-- — never claim hardware verification by accident).
-- 5. Creates the single-column `idx_results_source_type` for the
@@ -47,26 +47,37 @@ ALTER TABLE results
-- 2. Drop the legacy CHECK constraint --------------------------------------
--
-- Mig 019 installed `results_source_type_check` with the two-value domain
--- ('live_ble', 'manual_staff'). The new four-value domain supersedes it.
+-- ('live_ble', 'manual_staff'). The canonical 3-value domain supersedes it.
ALTER TABLE results
DROP CONSTRAINT IF EXISTS results_source_type_check;
--- 3. Back-fill legacy values onto the new domain ---------------------------
+-- 3. Back-fill legacy values onto the canonical 3-value domain -------------
--
--- 'manual_staff' → 'manual' (one-to-one mapping; the new domain drops the
--- now-redundant '_staff' suffix). 'live_ble' is preserved unchanged.
+-- 'manual_staff' → 'manual' (drops the redundant '_staff' suffix)
+-- 'legacy_csv' → 'imported_csv' (clarifies the canonical CSV-ingest tag
+-- installed by mig 020 / 024)
+-- 'live_ble' is preserved unchanged.
+--
+-- Both UPDATEs are idempotent (`WHERE source_type = ''`) — re-running
+-- is a no-op once the domain is canonicalised. CRITICAL: both migrations
+-- must run BEFORE the new CHECK constraint below; otherwise PG's
+-- ADD CONSTRAINT validation pass blocks on the legacy strings.
UPDATE results
SET source_type = 'manual'
WHERE source_type = 'manual_staff';
+UPDATE results
+SET source_type = 'imported_csv'
+WHERE source_type = 'legacy_csv';
+
-- 4. Realign the column DEFAULT to the new safe value ----------------------
ALTER TABLE results
ALTER COLUMN source_type SET DEFAULT 'manual';
--- 5. Install the new CHECK constraint --------------------------------------
+-- 5. Install the new CHECK constraint — canonical 3-value domain ----------
ALTER TABLE results
ADD CONSTRAINT results_source_type_check
- CHECK (source_type IN ('manual', 'live_ble', 'imported_csv', 'webhook'));
+ CHECK (source_type IN ('manual', 'live_ble', 'imported_csv'));
-- 6. Single-column index per spec ------------------------------------------
--
@@ -79,7 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_results_source_type
-- 7. Documentation ---------------------------------------------------------
COMMENT ON COLUMN results.source_type IS
- 'Provenance discriminator: manual | live_ble | imported_csv | webhook. ' ||
+ 'Provenance discriminator: manual | live_ble | imported_csv. ' ||
'Required on every insert via submit_result_secure. Hardware-verified ' ||
'rows (live_ble) are the only ones eligible for the cryptographic ' ||
'verification_hash signing path.';
diff --git a/migrations/015_composite_uniqueness_guard.sql b/migrations/015_composite_uniqueness_guard.sql
index 5f9f700..3e4536d 100644
--- a/migrations/015_composite_uniqueness_guard.sql
+++ b/migrations/015_composite_uniqueness_guard.sql
@@ -78,21 +78,41 @@ END $$;
-- WHERE athlete_id = p_athlete_id
-- AND drill_type = p_drill_type
-- AND recorded_at > now() - interval '120 seconds'
+-- ORDER BY recorded_at DESC
+-- LIMIT 1
--
-- Without an index, this is a sequential scan. With 10,000+ results across a
-- busy combine day, this adds measurable latency to every result submission.
--- This partial index covers exactly the hot path: recent rows only.
+--
+-- IMMUTABILITY CONSTRAINT:
+-- PostgreSQL forbids volatile functions (now(), CURRENT_TIMESTAMP, random())
+-- in CREATE INDEX predicates — managed Postgres providers (Supabase included)
+-- reject such migrations at provisioning time with
+-- `ERROR: functions in index predicate must be marked IMMUTABLE`.
+-- The previous predicate `WHERE recorded_at > (now() - interval '24 hours')`
+-- triggered exactly that failure on every fresh provision.
+--
+-- DESIGN:
+-- Use a stable compound B-Tree on (athlete_id, drill_type, recorded_at DESC).
+-- Postgres uses the leading two equality columns to seek and the trailing
+-- recorded_at DESC to satisfy the ORDER BY ... LIMIT 1 directly from the
+-- index — no sort, no heap scan beyond the single matching tuple. Bounding
+-- `recorded_at > now() - interval '120 seconds'` becomes a cheap range scan
+-- on the trailing index column, so the original hot-path latency goal is
+-- preserved without any volatile predicate.
+--
+-- The optional `WHERE voided IS DISTINCT FROM true` predicate keeps the
+-- index sparse: voided rows are explicitly excluded from suspicious-duplicate
+-- detection (see the matching filter inside submit_result_secure below), so
+-- they should not occupy index pages either. `IS DISTINCT FROM` correctly
+-- handles NULL (Postgres treats `voided = false` and `voided IS NULL` as
+-- "not voided"), and it is an immutable boolean expression — index-safe.
--
-- IF NOT EXISTS: idempotent.
-- ---------------------------------------------------------------------------
-CREATE INDEX IF NOT EXISTS idx_results_recent_by_athlete_drill
+CREATE INDEX IF NOT EXISTS idx_results_athlete_drill_time
ON results (athlete_id, drill_type, recorded_at DESC)
- WHERE recorded_at > (now() - interval '24 hours');
-
--- NOTE: The partial index predicate uses a static expression. PostgreSQL
--- evaluates it at index creation time, not at query time. For a combine that
--- runs within a single calendar day this is correct. For multi-day events,
--- run this migration again at the start of each day to refresh the index.
+ WHERE voided IS DISTINCT FROM true;
-- ---------------------------------------------------------------------------
-- Step 3: submit_result_secure v4 — adds suspicious-duplicate detection.
diff --git a/migrations/016a_athlete_lookup_rpc.sql b/migrations/016a_athlete_lookup_rpc.sql
new file mode 100644
index 0000000..c07e0d9
--- /dev/null
+++ b/migrations/016a_athlete_lookup_rpc.sql
@@ -0,0 +1,197 @@
+-- =============================================================================
+-- MIGRATION 016a: Athlete Lookup RPC + Public-Read Eradication
+-- Core Elite Combine 2026 · Mission "lookup_athlete_by_phone"
+-- =============================================================================
+--
+-- FILENAME NOTE: the mission spec asked for `migrations/016_athlete_lookup_rpc.sql`
+-- but slot `016_tier1_data_hardening.sql` is already taken by the Tier-1 data
+-- hardening work (DOB constraints, parent-email format, dedup unique index).
+-- Per the established 007a/008a/009a/010a/011a convention used everywhere
+-- else in this repo, we use `016a` so the lexical sort still applies the
+-- migrations in the correct order (016 → 016a → 017).
+--
+-- WHY this exists:
+-- The /lookup page lets parents type a phone number and receive their
+-- child's wristband number. Until this migration, the page hit the
+-- `athletes` table directly via `supabase.from('athletes').select(...)`
+-- which required a permissive RLS policy. The original `Public Read Own
+-- Athlete` policy (mig 010, line 33–36) granted `USING (true)` SELECT to
+-- the default/public role — meaning any anonymous client could enumerate
+-- the entire athletes table, scrape parent_phone, parent_email, DOB, and
+-- build a contact list. That is a single-policy data breach.
+--
+-- WHAT this does:
+-- 1. Drops the `Public Read Own Athlete` USING(true) policy so anon/public
+-- can no longer issue arbitrary SELECTs against `athletes`.
+-- 2. Creates `athlete_lookup_attempts` — an append-only ledger that backs
+-- the rate-limit gate inside the RPC (max 10 attempts per phone per
+-- 5-minute window). RLS is enabled with no public/anon policies; only
+-- the SECURITY DEFINER RPC writes here.
+-- 3. Creates `lookup_athlete_by_phone(p_phone TEXT, p_event_id UUID)` —
+-- the only sanctioned public path to athlete data on this page.
+-- • SECURITY DEFINER with explicit `SET search_path = public, pg_temp`
+-- to neutralize search-path injection.
+-- • Validates phone format (10 digits, normalized server-side).
+-- • Enforces the rate limit before any read.
+-- • Returns ONLY {id, first_name, last_name, event_id, band_display} —
+-- deliberately omits parent_phone, parent_email, DOB, position,
+-- height/weight, etc. so a successful call exposes the minimum
+-- needed to identify a wristband.
+-- • Scoped to a single event via `p_event_id` so a parent must already
+-- know which event their child is registered for; cross-event
+-- scraping is blocked.
+-- 4. REVOKEs PUBLIC, GRANTs EXECUTE only to anon + authenticated.
+--
+-- WHAT this does NOT touch:
+-- • `athletes_anon_public_select` (from 010a_rls_hardening.sql line 248):
+-- this is narrowly scoped to is_public events via an EXISTS subquery —
+-- it does NOT use `USING (true)` and falls outside the explicit
+-- anti-pattern. The scout pages (Leaderboard, AthleteDetail) depend on
+-- it and live outside Mission scope.
+-- • `Staff Read Athletes` (TO authenticated USING (true)) and the
+-- tenant-scoped policies installed by 010a — staff still need broad
+-- read access via the admin app; the Mission anti-pattern explicitly
+-- excludes authenticated roles.
+--
+-- IDEMPOTENCY: Re-runnable.
+-- DROP POLICY IF EXISTS, CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT
+-- EXISTS, CREATE OR REPLACE FUNCTION, REVOKE/GRANT are all idempotent.
+-- =============================================================================
+
+BEGIN;
+
+-- ---------------------------------------------------------------------------
+-- 1. Drop the USING(true) public SELECT policy on athletes.
+-- This is the exact target the Mission anti-pattern names. Mig 010
+-- installed it as a temporary bridge ("Public SELECT remains open —
+-- parent portal and lookup pages need it"). The new RPC supersedes that
+-- bridge.
+-- ---------------------------------------------------------------------------
+DROP POLICY IF EXISTS "Public Read Own Athlete" ON athletes;
+
+-- Defensive: also drop any historical names that mig 002 / 001 may have
+-- installed under different casings. None of these should still exist on a
+-- fully-migrated database; the IF EXISTS guard makes the line a no-op when
+-- they don't.
+DROP POLICY IF EXISTS "Public Read Athletes" ON athletes;
+DROP POLICY IF EXISTS "Public Insert Athletes" ON athletes;
+
+-- ---------------------------------------------------------------------------
+-- 2. Rate-limit ledger.
+-- Append-only. The RPC inserts one row per attempt and counts rows in
+-- the trailing 5-minute window. Service role + the SECURITY DEFINER RPC
+-- are the only writers; no anon/public/authenticated policies.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS athlete_lookup_attempts (
+ id BIGSERIAL PRIMARY KEY,
+ phone_digits TEXT NOT NULL,
+ event_id UUID,
+ attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ matched_count INT
+);
+
+-- Hot path: WHERE phone_digits = $1 AND attempted_at > now() - interval '...'
+-- The trailing recorded_at DESC lets the LIMIT 1 / count(*) seek directly.
+CREATE INDEX IF NOT EXISTS idx_athlete_lookup_attempts_phone_time
+ ON athlete_lookup_attempts (phone_digits, attempted_at DESC);
+
+ALTER TABLE athlete_lookup_attempts ENABLE ROW LEVEL SECURITY;
+ALTER TABLE athlete_lookup_attempts FORCE ROW LEVEL SECURITY;
+-- (Intentionally NO policies. SECURITY DEFINER RPC + service role only.)
+
+-- ---------------------------------------------------------------------------
+-- 3. lookup_athlete_by_phone — the only sanctioned public read path.
+--
+-- Return shape is fixed to the Mission spec:
+-- id, first_name, last_name, event_id, band_display
+-- Anything beyond that (parent_phone, parent_email, DOB, position,
+-- height/weight) is excluded by design.
+--
+-- `SET search_path = public, pg_temp` is mandatory for SECURITY DEFINER
+-- functions that reference unqualified relation names — without it a
+-- malicious caller with CREATE on a temp/parallel schema could shadow
+-- `athletes` / `bands` and harvest writes. `pg_temp` is appended last
+-- per the standard CVE-2018-1058 mitigation pattern.
+-- ---------------------------------------------------------------------------
+CREATE OR REPLACE FUNCTION lookup_athlete_by_phone(
+ p_phone TEXT,
+ p_event_id UUID
+)
+RETURNS TABLE (
+ id UUID,
+ first_name TEXT,
+ last_name TEXT,
+ event_id UUID,
+ band_display TEXT
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, pg_temp
+AS $$
+DECLARE
+ v_digits TEXT;
+ v_recent_attempts INT;
+BEGIN
+ -- ── Input validation ─────────────────────────────────────────────────────
+ IF p_phone IS NULL OR p_event_id IS NULL THEN
+ RAISE EXCEPTION 'phone and event_id are required'
+ USING ERRCODE = '22023'; -- invalid_parameter_value
+ END IF;
+
+ -- Normalize: strip every non-digit. Defense in depth — the client also
+ -- normalizes, but never trust client-side stripping.
+ v_digits := regexp_replace(p_phone, '\D', '', 'g');
+
+ IF length(v_digits) <> 10 THEN
+ RAISE EXCEPTION 'phone must contain exactly 10 digits'
+ USING ERRCODE = '22023';
+ END IF;
+
+ -- ── Rate limit ───────────────────────────────────────────────────────────
+ -- 10 attempts per phone per 5 minutes. Sized to absorb a parent
+ -- mistyping a few times while shutting down a scraper that's iterating
+ -- through area codes.
+ SELECT count(*)
+ INTO v_recent_attempts
+ FROM athlete_lookup_attempts
+ WHERE phone_digits = v_digits
+ AND attempted_at > now() - interval '5 minutes';
+
+ IF v_recent_attempts >= 10 THEN
+ RAISE EXCEPTION 'rate limit exceeded — please try again in a few minutes'
+ USING ERRCODE = '53400'; -- configuration_limit_exceeded
+ END IF;
+
+ -- Record the attempt BEFORE the SELECT so a successful flood still
+ -- accrues against the limit (vs. only failed attempts).
+ INSERT INTO athlete_lookup_attempts (phone_digits, event_id)
+ VALUES (v_digits, p_event_id);
+
+ -- ── Narrow read ──────────────────────────────────────────────────────────
+ RETURN QUERY
+ SELECT a.id,
+ a.first_name,
+ a.last_name,
+ a.event_id,
+ b.display_number::TEXT AS band_display
+ FROM athletes a
+ LEFT JOIN bands b
+ ON b.band_id = a.band_id
+ WHERE a.parent_phone = v_digits
+ AND a.event_id = p_event_id
+ ORDER BY a.created_at DESC
+ LIMIT 5;
+END;
+$$;
+
+-- Lock down execution: no PUBLIC, only the two real roles that hit PostgREST.
+REVOKE ALL ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) TO anon, authenticated;
+
+COMMENT ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) IS
+ 'Public parent-portal lookup. Returns at most 5 athlete identity rows ' ||
+ '(id, first_name, last_name, event_id, band_display) for a phone+event ' ||
+ 'pair. Rate-limited at 10/5min/phone via athlete_lookup_attempts. ' ||
+ 'SECURITY DEFINER + SET search_path defends against CVE-2018-1058.';
+
+COMMIT;
diff --git a/migrations/019_verification_hash.sql b/migrations/019_verification_hash.sql
index b111782..54459b1 100644
--- a/migrations/019_verification_hash.sql
+++ b/migrations/019_verification_hash.sql
@@ -14,13 +14,13 @@
-- 1. results.verification_hash TEXT (nullable)
-- Populated by the generate-verified-export Edge Function.
-- HMAC-SHA-256(VERIFICATION_SECRET, canonical_payload_string).
--- NULL = result has not been verified yet, or source_type = 'manual_staff'.
+-- NULL = result has not been verified yet, or source_type = 'manual'.
--
--- 2. results.source_type TEXT NOT NULL DEFAULT 'manual_staff'
+-- 2. results.source_type TEXT NOT NULL DEFAULT 'manual'
-- Set by the client at write time.
-- 'live_ble' — result was captured via BLE hardware; eligible for
-- cryptographic verification.
--- 'manual_staff' — result was entered manually; can never be
+-- 'manual' — result was entered manually; can never be
-- hardware-verified; verification_hash remains NULL.
--
-- 3. results.session_id TEXT (nullable)
@@ -40,7 +40,7 @@
-- Also embedded in the verification hash payload.
--
-- 6. submit_result_secure v6 — adds p_source_type and p_session_id params.
--- Existing callers without the new params get 'manual_staff' / NULL.
+-- Existing callers without the new params get 'manual' / NULL.
--
-- 7. upsert_capture_telemetry_lww v2 — adds p_clock_offset_ms and p_rtt_ms.
-- Existing callers default to NULL for both.
@@ -57,7 +57,7 @@ BEGIN;
-- ---------------------------------------------------------------------------
ALTER TABLE results ADD COLUMN IF NOT EXISTS verification_hash TEXT;
-ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual_staff';
+ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE results ADD COLUMN IF NOT EXISTS session_id TEXT;
-- Check constraint: only valid source types
@@ -69,7 +69,7 @@ BEGIN
) THEN
ALTER TABLE results
ADD CONSTRAINT results_source_type_check
- CHECK (source_type IN ('live_ble', 'manual_staff'));
+ CHECK (source_type IN ('live_ble', 'manual'));
END IF;
END $$;
@@ -77,7 +77,7 @@ END $$;
-- If a result has a matching capture_telemetry row, it was hardware-captured.
UPDATE results r
SET source_type = 'live_ble'
-WHERE source_type = 'manual_staff'
+WHERE source_type = 'manual'
AND EXISTS (
SELECT 1 FROM capture_telemetry ct WHERE ct.result_id = r.id
);
@@ -121,7 +121,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure(
p_attempt_number INT DEFAULT 1,
p_meta JSONB DEFAULT '{}'::jsonb,
p_device_timestamp BIGINT DEFAULT 0,
- p_source_type TEXT DEFAULT 'manual_staff',
+ p_source_type TEXT DEFAULT 'manual',
p_session_id TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql
diff --git a/migrations/024_enterprise_importer.sql b/migrations/024_enterprise_importer.sql
index e2b6379..aa06840 100644
--- a/migrations/024_enterprise_importer.sql
+++ b/migrations/024_enterprise_importer.sql
@@ -13,16 +13,16 @@
--
-- 2. results.band_id / station_id — made nullable.
-- Legacy imports have no band or station context. NULL is only valid when
--- source_type = 'legacy_csv'. Enforced by CHECK constraint below.
+-- source_type = 'imported_csv'. Enforced by CHECK constraint below.
--
-- 3. submit_result_secure v7 — adds is_hardware_verified to INSERT.
--- live_ble → true. manual_staff / legacy_csv → false.
+-- live_ble → true. manual / imported_csv → false.
--
-- 4. import_legacy_results_batch(p_event_id UUID, p_records JSONB)
-- Batch RPC for the EnterpriseImporter UI. Deduplicates athletes by
-- (first_name, last_name) and parent_email within the event, creates stubs
-- for unknowns, inserts results with is_hardware_verified = false and
--- source_type = 'legacy_csv'. Returns summary JSONB.
+-- source_type = 'imported_csv'. Returns summary JSONB.
--
-- IDEMPOTENCY: Safe to run multiple times (IF NOT EXISTS / CREATE OR REPLACE).
-- =============================================================================
@@ -59,7 +59,7 @@ ALTER TABLE results DROP CONSTRAINT IF EXISTS results_legacy_nullable_check;
ALTER TABLE results
ADD CONSTRAINT results_legacy_nullable_check
CHECK (
- source_type = 'legacy_csv'
+ source_type = 'imported_csv'
OR (band_id IS NOT NULL AND station_id IS NOT NULL)
);
@@ -68,7 +68,7 @@ ALTER TABLE results
--
-- Identical to v6 (migration 019) except is_hardware_verified added to INSERT.
-- is_hardware_verified = (p_source_type = 'live_ble') — evaluated at write time.
--- All existing callers get false by default (p_source_type defaults 'manual_staff').
+-- All existing callers get false by default (p_source_type defaults 'manual').
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION submit_result_secure(
@@ -82,7 +82,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure(
p_attempt_number INT DEFAULT 1,
p_meta JSONB DEFAULT '{}'::jsonb,
p_device_timestamp BIGINT DEFAULT 0,
- p_source_type TEXT DEFAULT 'manual_staff',
+ p_source_type TEXT DEFAULT 'manual',
p_session_id TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql
@@ -187,7 +187,7 @@ $$;
--
-- Inserted results always have:
-- is_hardware_verified = false (cannot be promoted — permanent)
--- source_type = 'legacy_csv'
+-- source_type = 'imported_csv'
-- band_id = NULL (no station context for legacy data)
-- station_id = NULL
-- ---------------------------------------------------------------------------
@@ -306,7 +306,7 @@ BEGIN
) VALUES (
gen_random_uuid(), p_event_id, v_athlete_id,
'forty', v_drill_val, 1,
- 'legacy_csv', false, 'clean', now(),
+ 'imported_csv', false, 'clean', now(),
jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email)
);
v_count_inserted := v_count_inserted + 1;
@@ -324,7 +324,7 @@ BEGIN
) VALUES (
gen_random_uuid(), p_event_id, v_athlete_id,
'shuttle_5_10_5', v_drill_val, 1,
- 'legacy_csv', false, 'clean', now(),
+ 'imported_csv', false, 'clean', now(),
jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email)
);
v_count_inserted := v_count_inserted + 1;
@@ -342,7 +342,7 @@ BEGIN
) VALUES (
gen_random_uuid(), p_event_id, v_athlete_id,
'vertical', v_drill_val, 1,
- 'legacy_csv', false, 'clean', now(),
+ 'imported_csv', false, 'clean', now(),
jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email)
);
v_count_inserted := v_count_inserted + 1;
@@ -360,7 +360,7 @@ BEGIN
) VALUES (
gen_random_uuid(), p_event_id, v_athlete_id,
'broad', v_drill_val, 1,
- 'legacy_csv', false, 'clean', now(),
+ 'imported_csv', false, 'clean', now(),
jsonb_build_object('import_source', 'enterprise_importer', 'importer_email', v_email)
);
v_count_inserted := v_count_inserted + 1;
diff --git a/packages/powersync/src/connector.ts b/packages/powersync/src/connector.ts
index 69964c9..1e981e3 100644
--- a/packages/powersync/src/connector.ts
+++ b/packages/powersync/src/connector.ts
@@ -227,7 +227,7 @@ export class CoreElitePowerSyncConnector implements PowerSyncBackendConnector {
// v5: device_timestamp is the LWW key (migration 018).
// Defaults to 0 on the server if not supplied (backwards compatible).
p_device_timestamp: (opData.device_timestamp as number) ?? 0,
- p_source_type: (opData.source_type as string) ?? 'manual_staff',
+ p_source_type: (opData.source_type as string) ?? 'manual',
p_session_id: (opData.session_id as string) ?? null,
});
diff --git a/packages/powersync/src/schema.ts b/packages/powersync/src/schema.ts
index 9fd7fc2..5ee3571 100644
--- a/packages/powersync/src/schema.ts
+++ b/packages/powersync/src/schema.ts
@@ -59,7 +59,7 @@ const results = new Table({
validation_status: new Column({ type: ColumnType.TEXT }),
hlc_timestamp: new Column({ type: ColumnType.TEXT }), // HLC: causal ordering
device_timestamp: new Column({ type: ColumnType.INTEGER }), // LWW key: device wall-clock ms
- source_type: new Column({ type: ColumnType.TEXT }), // 'live_ble' | 'manual_staff'
+ source_type: new Column({ type: ColumnType.TEXT }), // 'live_ble' | 'manual' | 'imported_csv'
session_id: new Column({ type: ColumnType.TEXT }), // combine wave/session
verification_hash: new Column({ type: ColumnType.TEXT }), // HMAC-SHA-256, set by Edge Fn
recorded_at: new Column({ type: ColumnType.TEXT }),
diff --git a/packages/powersync/src/useSync.ts b/packages/powersync/src/useSync.ts
index 4659769..722d593 100644
--- a/packages/powersync/src/useSync.ts
+++ b/packages/powersync/src/useSync.ts
@@ -279,10 +279,12 @@ export interface ResultPayload {
device_timestamp?: number;
/**
* 'live_ble' — captured via BLE hardware; eligible for cryptographic verification.
- * 'manual_staff' — manually keyed; verification_hash will always be null.
- * Defaults to 'manual_staff' when omitted.
+ * 'manual' — manually keyed; verification_hash will always be null.
+ * 'imported_csv' — bulk-ingested historical record.
+ * Defaults to 'manual' when omitted (defense-in-depth — the StationMode
+ * capture path always sets this explicitly per Mission "p_source_type").
*/
- source_type?: 'live_ble' | 'manual_staff';
+ source_type?: 'live_ble' | 'manual' | 'imported_csv';
/** Combine wave/session identifier for bulk session exports. */
session_id?: string;
meta?: Record;
@@ -348,7 +350,7 @@ export function useSyncedWrite() {
const id = payload.client_result_id;
const now = new Date().toISOString();
const deviceTimestamp = payload.device_timestamp ?? Date.now();
- const sourceType = payload.source_type ?? 'manual_staff';
+ const sourceType = payload.source_type ?? 'manual';
await db.execute(`
INSERT OR REPLACE INTO results (
diff --git a/src/App.tsx b/src/App.tsx
index f992a1e..0801ecb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import { SyncIndicator } from './components/SyncIndicator';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RouteGuard } from './components/RouteGuard';
import { ThemeProvider } from './components/ThemeProvider';
+import { SyncProvider } from './contexts/SyncProvider';
import { reportNav } from './lib/apm';
// APM route-timing beacon — mounts once inside and fires a
@@ -75,6 +76,7 @@ export default function App() {
+
@@ -176,6 +178,7 @@ export default function App() {
+
diff --git a/src/components/CoachRadarChart.tsx b/src/components/CoachRadarChart.tsx
new file mode 100644
index 0000000..a7b121c
--- /dev/null
+++ b/src/components/CoachRadarChart.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {
+ RadarChart,
+ PolarGrid,
+ PolarAngleAxis,
+ Radar,
+ ResponsiveContainer,
+ Tooltip,
+} from 'recharts';
+
+// Standalone radar chart so recharts (~120KB min) is split into its own Vite
+// chunk and never reaches the initial CoachPortal payload. Imported via
+// React.lazy from CoachPortal so the chart bundle is fetched only when an
+// operator actually toggles compare-mode and selects ≥2 athletes.
+
+export interface CoachRadarDatum {
+ drill: string;
+ [athleteKey: string]: string | number;
+}
+
+export interface CoachRadarAthlete {
+ id: string;
+ first_name: string;
+ last_name: string;
+}
+
+interface CoachRadarChartProps {
+ data: CoachRadarDatum[];
+ athletes: CoachRadarAthlete[];
+ colors: string[];
+}
+
+const CoachRadarChart: React.FC = ({ data, athletes, colors }) => (
+
+
+
+
+ [`${value}th percentile`]}
+ contentStyle={{ borderRadius: '12px', border: '1px solid #e4e4e7', fontSize: 12 }}
+ />
+ {athletes.map((athlete, i) => (
+
+ ))}
+
+
+);
+
+export default CoachRadarChart;
diff --git a/src/components/FilmEmbed.tsx b/src/components/FilmEmbed.tsx
index f54482d..3c4dc83 100644
--- a/src/components/FilmEmbed.tsx
+++ b/src/components/FilmEmbed.tsx
@@ -65,7 +65,7 @@ export default function FilmEmbed({ filmUrl, title }: FilmEmbedProps) {
-
+
{`> UNSUPPORTED PROVIDER. DIRECT LINK ONLY.`}
@@ -95,7 +95,7 @@ export default function FilmEmbed({ filmUrl, title }: FilmEmbedProps) {
FILM · {providerLabel[parsed.provider]}
{title && (
-
+
· {title}
)}
diff --git a/src/components/HardwareStandby.tsx b/src/components/HardwareStandby.tsx
index a0f2d56..a248db8 100644
--- a/src/components/HardwareStandby.tsx
+++ b/src/components/HardwareStandby.tsx
@@ -221,7 +221,7 @@ export default function HardwareStandby({ onRetry }: HardwareStandbyProps) {
SYS ONLINE
- Role Legend
+ Role Legend
{ROLES.map(r => (
{r.label}
diff --git a/src/types/supabase.ts b/src/types/supabase.ts
index db02887..bb6e86c 100644
--- a/src/types/supabase.ts
+++ b/src/types/supabase.ts
@@ -49,6 +49,9 @@ export type Json =
export type SubmitResultVersion = '5' | '6';
+// Canonical provenance vocabulary — matches src/lib/types.ts SourceType.
+export type SourceType = 'manual' | 'live_ble' | 'imported_csv';
+
export interface SubmitResultPayloadV5 {
client_result_id: string;
event_id: string;
diff --git a/supabase/functions/generate-verified-export/index.ts b/supabase/functions/generate-verified-export/index.ts
index de96f50..c14858e 100644
--- a/supabase/functions/generate-verified-export/index.ts
+++ b/supabase/functions/generate-verified-export/index.ts
@@ -19,7 +19,7 @@
* VERIFICATION_SECRET and the same canonical fields — proving the data
* transited through this server-side function unmodified.
*
- * Manual entries (source_type = 'manual_staff') are included in the export
+ * Manual entries (source_type = 'manual') are included in the export
* with verification_hash = null and verification_status = 'unverified_manual'.
* There is no path to hardware-verify a manually entered result.
*
@@ -183,7 +183,7 @@ interface ResultRow {
type VerificationStatus =
| 'hardware_verified' // live_ble, hash computed, clock sync present
| 'hardware_no_clock' // live_ble, hash computed, no clock sync data
- | 'unverified_manual'; // manual_staff, no hardware attestation possible
+ | 'unverified_manual'; // manual entry, no hardware attestation possible
interface ExportResult {
result_id: string;
@@ -191,7 +191,11 @@ interface ExportResult {
value_num: number;
attempt_number: number;
recorded_at: string;
- source_type: 'live_ble' | 'manual_staff';
+ // Canonical SourceType (src/lib/types.ts). Hardware-verified rows are
+ // 'live_ble' only; everything else is the unverified manual or imported
+ // path. Verified-export only includes rows whose source_type is in this
+ // narrow union — imported_csv rows are filtered out at the query level.
+ source_type: 'live_ble' | 'manual';
verification_status: VerificationStatus;
verification_hash: string | null;
/** The exact string that was hashed. Null for manual entries. */
@@ -357,14 +361,14 @@ Deno.serve(async (req: Request): Promise => {
});
} else {
- // manual_staff — hardware verification is not possible, by design
+ // 'manual' — hardware verification is not possible, by design
exportResults.push({
result_id: row.result_id,
drill_type: row.drill_type,
value_num: row.value_num,
attempt_number: row.attempt_number,
recorded_at: row.recorded_at,
- source_type: 'manual_staff',
+ source_type: 'manual',
verification_status: 'unverified_manual',
verification_hash: null,
verification_payload: null,
@@ -411,7 +415,7 @@ Deno.serve(async (req: Request): Promise => {
// ── Step 8: Return the signed export payload ───────────────────────────
const verifiedCount = exportResults.filter(r => r.source_type === 'live_ble').length;
- const unverifiedCount = exportResults.filter(r => r.source_type === 'manual_staff').length;
+ const unverifiedCount = exportResults.filter(r => r.source_type === 'manual').length;
const payload = {
generated_at: new Date().toISOString(),
diff --git a/supabase/functions/process-vendor-import/index.ts b/supabase/functions/process-vendor-import/index.ts
index b12518e..73ab40c 100644
--- a/supabase/functions/process-vendor-import/index.ts
+++ b/supabase/functions/process-vendor-import/index.ts
@@ -3,7 +3,7 @@
* Core Elite — Phase 3: Historical Data Import Pipeline
*
* Accepts a JSON payload of pre-parsed legacy CSV records and bulk-inserts
- * them into the results table with source_type = 'legacy_csv'.
+ * them into the results table with source_type = 'imported_csv'.
*
* Security:
* JWT gateway verification is disabled (verify_jwt = false in config.toml)
@@ -16,10 +16,10 @@
* constraint and be counted as 'skipped' rather than errored.
*
* Realtime isolation:
- * Rows inserted with source_type = 'legacy_csv' are excluded from live
+ * Rows inserted with source_type = 'imported_csv' are excluded from live
* dashboard subscriptions via a Realtime filter. This function does not
* suppress the Postgres publication directly — that filtering is done
- * on the subscriber side (LiveCommandCenter: source_type=neq.legacy_csv).
+ * on the subscriber side (LiveCommandCenter: source_type=neq.imported_csv).
*
* Request body (JSON):
* {
@@ -413,7 +413,13 @@ Deno.serve(async (req: Request): Promise => {
? new Date(r.recordedAt).getTime()
: importTimestamp;
- // Deterministic client_result_id for idempotent re-imports
+ // Deterministic client_result_id for idempotent re-imports.
+ // The seed prefix is kept as the LITERAL 'legacy_csv' (NOT the
+ // canonical 'imported_csv' source_type value) so that re-imports
+ // of historical CSVs continue to produce the same client_result_id
+ // they always did. The seed is an opaque hash input — it does not
+ // appear anywhere user-facing, and changing it would invalidate
+ // the idempotency contract for every prior import.
const seed = [
'legacy_csv', event_id, r.athleteId,
r.drillType, r.attemptNum, r.valueNum, recordedAt,
@@ -429,7 +435,7 @@ Deno.serve(async (req: Request): Promise => {
drill_type: r.drillType,
value_num: r.valueNum,
attempt_number: r.attemptNum,
- source_type: 'legacy_csv',
+ source_type: 'imported_csv',
validation_status: 'clean',
device_timestamp: deviceTs,
recorded_at: recordedAt,
diff --git a/supabase/migrations/20260412000019_verification_hash.sql b/supabase/migrations/20260412000019_verification_hash.sql
index b111782..54459b1 100644
--- a/supabase/migrations/20260412000019_verification_hash.sql
+++ b/supabase/migrations/20260412000019_verification_hash.sql
@@ -14,13 +14,13 @@
-- 1. results.verification_hash TEXT (nullable)
-- Populated by the generate-verified-export Edge Function.
-- HMAC-SHA-256(VERIFICATION_SECRET, canonical_payload_string).
--- NULL = result has not been verified yet, or source_type = 'manual_staff'.
+-- NULL = result has not been verified yet, or source_type = 'manual'.
--
--- 2. results.source_type TEXT NOT NULL DEFAULT 'manual_staff'
+-- 2. results.source_type TEXT NOT NULL DEFAULT 'manual'
-- Set by the client at write time.
-- 'live_ble' — result was captured via BLE hardware; eligible for
-- cryptographic verification.
--- 'manual_staff' — result was entered manually; can never be
+-- 'manual' — result was entered manually; can never be
-- hardware-verified; verification_hash remains NULL.
--
-- 3. results.session_id TEXT (nullable)
@@ -40,7 +40,7 @@
-- Also embedded in the verification hash payload.
--
-- 6. submit_result_secure v6 — adds p_source_type and p_session_id params.
--- Existing callers without the new params get 'manual_staff' / NULL.
+-- Existing callers without the new params get 'manual' / NULL.
--
-- 7. upsert_capture_telemetry_lww v2 — adds p_clock_offset_ms and p_rtt_ms.
-- Existing callers default to NULL for both.
@@ -57,7 +57,7 @@ BEGIN;
-- ---------------------------------------------------------------------------
ALTER TABLE results ADD COLUMN IF NOT EXISTS verification_hash TEXT;
-ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual_staff';
+ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE results ADD COLUMN IF NOT EXISTS session_id TEXT;
-- Check constraint: only valid source types
@@ -69,7 +69,7 @@ BEGIN
) THEN
ALTER TABLE results
ADD CONSTRAINT results_source_type_check
- CHECK (source_type IN ('live_ble', 'manual_staff'));
+ CHECK (source_type IN ('live_ble', 'manual'));
END IF;
END $$;
@@ -77,7 +77,7 @@ END $$;
-- If a result has a matching capture_telemetry row, it was hardware-captured.
UPDATE results r
SET source_type = 'live_ble'
-WHERE source_type = 'manual_staff'
+WHERE source_type = 'manual'
AND EXISTS (
SELECT 1 FROM capture_telemetry ct WHERE ct.result_id = r.id
);
@@ -121,7 +121,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure(
p_attempt_number INT DEFAULT 1,
p_meta JSONB DEFAULT '{}'::jsonb,
p_device_timestamp BIGINT DEFAULT 0,
- p_source_type TEXT DEFAULT 'manual_staff',
+ p_source_type TEXT DEFAULT 'manual',
p_session_id TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql
diff --git a/supabase/migrations/20260415000020_legacy_import.sql b/supabase/migrations/20260415000020_legacy_import.sql
index bc8a42b..490f86c 100644
--- a/supabase/migrations/20260415000020_legacy_import.sql
+++ b/supabase/migrations/20260415000020_legacy_import.sql
@@ -5,18 +5,18 @@
--
-- CHANGES:
--
--- 1. Expands the results.source_type check constraint to include 'legacy_csv'.
+-- 1. Expands the results.source_type check constraint to include 'imported_csv'.
-- This allows the process-vendor-import Edge Function to tag imported
-- rows distinctly from live BLE captures and manual staff entries.
--
--- 2. Partial index on source_type = 'legacy_csv' so admin queries (e.g.
+-- 2. Partial index on source_type = 'imported_csv' so admin queries (e.g.
-- "show only imported records") stay fast as the table grows.
--
-- 3. Partial index for Realtime filter: Supabase Realtime subscriptions can
--- now use filter = 'source_type=neq.legacy_csv' to exclude imports from
+-- now use filter = 'source_type=neq.imported_csv' to exclude imports from
-- live dashboards without a full-table scan.
--
--- WHY NOT USE 'manual_staff':
+-- WHY NOT USE 'manual':
-- Legacy CSV rows differ semantically from staff manual entry — they lack a
-- station operator, may predate the current event, and should be hidden from
-- real-time combine dashboards. Using a dedicated source_type makes filtering
@@ -38,7 +38,7 @@ BEGIN;
-- Ensure the column exists (idempotent IF NOT EXISTS)
ALTER TABLE results
- ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual_staff';
+ ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual';
-- Also ensure other columns from 019 exist in case that migration was skipped
ALTER TABLE results ADD COLUMN IF NOT EXISTS verification_hash TEXT;
@@ -49,7 +49,7 @@ ALTER TABLE results DROP CONSTRAINT IF EXISTS results_source_type_check;
ALTER TABLE results
ADD CONSTRAINT results_source_type_check
- CHECK (source_type IN ('live_ble', 'manual_staff', 'legacy_csv'));
+ CHECK (source_type IN ('live_ble', 'manual', 'imported_csv'));
-- Back-fill any rows that may have been inserted before the constraint with
-- unexpected values — defensive only, should be a no-op on a healthy DB.
@@ -59,9 +59,9 @@ ALTER TABLE results
-- (Use only columns guaranteed to exist across all schema versions)
-- ---------------------------------------------------------------------------
-CREATE INDEX IF NOT EXISTS idx_results_legacy_csv
+CREATE INDEX IF NOT EXISTS idx_results_imported_csv
ON results (event_id, athlete_id, drill_type)
- WHERE source_type = 'legacy_csv';
+ WHERE source_type = 'imported_csv';
-- ---------------------------------------------------------------------------
-- 3. Partial index: non-legacy rows (supports Realtime filter exclusion)
@@ -69,6 +69,6 @@ CREATE INDEX IF NOT EXISTS idx_results_legacy_csv
CREATE INDEX IF NOT EXISTS idx_results_non_legacy
ON results (event_id)
- WHERE source_type != 'legacy_csv';
+ WHERE source_type != 'imported_csv';
COMMIT;
diff --git a/supabase/migrations/20260422000024_rpc_versioning_matrix.sql b/supabase/migrations/20260422000024_rpc_versioning_matrix.sql
index fbfef31..aeb2f85 100644
--- a/supabase/migrations/20260422000024_rpc_versioning_matrix.sql
+++ b/supabase/migrations/20260422000024_rpc_versioning_matrix.sql
@@ -157,7 +157,7 @@ BEGIN
v_attempt_number := COALESCE((p_payload->>'attempt_number')::INT, 1);
v_meta := COALESCE(p_payload->'meta', '{}'::jsonb);
v_device_timestamp := COALESCE((p_payload->>'device_timestamp')::BIGINT, 0);
- v_source_type := COALESCE(p_payload->>'source_type', 'manual_staff');
+ v_source_type := COALESCE(p_payload->>'source_type', 'manual');
v_session_id := p_payload->>'session_id'; -- may be NULL
-- Gate 0: Authentication (same as v6)
@@ -453,7 +453,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure(
p_attempt_number INT DEFAULT 1,
p_meta JSONB DEFAULT '{}'::jsonb,
p_device_timestamp BIGINT DEFAULT 0,
- p_source_type TEXT DEFAULT 'manual_staff',
+ p_source_type TEXT DEFAULT 'manual',
p_session_id TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql
diff --git a/supabase/migrations/20260423000025_rpc_versioning_align_v5_v6.sql b/supabase/migrations/20260423000025_rpc_versioning_align_v5_v6.sql
index 0f0cf0d..a00ad0b 100644
--- a/supabase/migrations/20260423000025_rpc_versioning_align_v5_v6.sql
+++ b/supabase/migrations/20260423000025_rpc_versioning_align_v5_v6.sql
@@ -257,7 +257,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure(
p_attempt_number INT DEFAULT 1,
p_meta JSONB DEFAULT '{}'::jsonb,
p_device_timestamp BIGINT DEFAULT 0,
- p_source_type TEXT DEFAULT 'manual_staff',
+ p_source_type TEXT DEFAULT 'manual',
p_session_id TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql