Skip to content

fix(baseball): CoachHelm engine loaders/registry — #379 Phase 4a - #851

Merged
njrini99-code merged 1 commit into
batch/bbh-finish-0714from
task/379-engine-4a
Jul 15, 2026
Merged

fix(baseball): CoachHelm engine loaders/registry — #379 Phase 4a#851
njrini99-code merged 1 commit into
batch/bbh-finish-0714from
task/379-engine-4a

Conversation

@njrini99-code

Copy link
Copy Markdown
Owner

Summary

Implements the design chunk "CoachHelm engine Phase 4a — loaders + registry" from the #379 stats-layer reconciliation design, building on #827 (seed/demo reconciliation) and #828 (shared legacy-stat-adapters.ts), both already on batch/bbh-finish-0714.

Base-branch verification (per task instructions)

  • git log origin/batch/bbh-finish-0714 --oneline | grep -i clock → found dcaa59fb fix(baseball): thread engine's nowIso through rolling-window loaders (#811), folded in via ff54f8a5 merge main (post #808/#811) into batch/bbh-finish-0714. No merge needed — the base branch already has fix(baseball): thread engine's nowIso through rolling-window loaders #811's nowIso threading through loadLiftMetrics/loadReadinessMetrics/loadPlayerMetrics/loadCatchingMetrics. Confirmed nowIso stays the 3rd positional param with its existing default in loadPlayerMetrics/loadAllPlayerMetrics — every new parameter is appended after it.

What changed (exactly the chunk's 5 files, nothing else)

  • src/lib/coachhelm/baseball/loaders.ts
    • Added optional per-row hittingSourceTable / pitchingSourceTable tags on BoxScoreRow, honestly cited in source_refs instead of the hardcoded 'baseball_player_stats' literal. Two new pure normalizers — normalizeBoxScoreBattingRow / normalizeBoxScorePitchingRow — map canonical baseball_box_score_batting/_pitching rows into the loader's existing internal shape (same math, different input), ready for the Phase 4b callers to adopt.
    • Added an optional eventDerived input (appended after nowIso, so it's fully backward compatible) that lets exit/pitch velocity metrics prefer real event-grain data from elite-stat-events.ts over the legacy scalar columns — per field, never blended, never fabricated. eventDerivedVelocityFromMetrics() extracts avg_exit_velocity / avg_velocity from that module's DerivedMetric[] output (no max_* event metric exists yet, so max fields honestly keep falling back to the legacy scalar).
    • Math is untouched for every existing caller: all 4 current call sites (engine-run.ts, outcome-sweep.ts, action-baseline.ts, practice-effectiveness.ts — none touched by this PR, all Phase 4b/2 concerns) call with the old 3-arg signature and get byte-identical output — verified by running their existing test suites unchanged (39/39 passing).
  • src/lib/coachhelm/baseball/metrics/registry.ts — header doc comment no longer asserts baseball_player_stats/baseball_player_aggregates as the metrics' only source (loaders.ts can now source some fields canonically); zero remaining references to the deprecated tables in this file. Metric IDs/metadata (direction, fidelity, thresholds) are unchanged.
  • src/lib/coachhelm/baseball/engine-v10.test.ts — new describe('loaders #379 reconciliation...') block covering: legacy-fallback behavior unchanged, event-derived override wins per-field, box-score-normalizer source-table threading, eventDerivedVelocityFromMetrics extraction (present + absent cases), and loadAllPlayerMetrics's nowIso positional preservation + per-player event map threading.
  • src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts — fixture table: literal switched from 'baseball_player_stats' to 'baseball_box_score_batting' (unrelated to the test's actual assertions).
  • src/lib/baseball/stat-layer-manifest.ts — removed the now-stale registry.ts entry (file no longer references a deprecated table) and the registry.role-visibility.test.ts entry (fixture migrated); updated loaders.ts's note to describe the new additive capabilities; restored engine-v10.test.ts's entry (still legitimately pins the legacy-fallback path in its new tests).

Gates

  • npm run typecheck → clean, exit 0.
  • npx eslint --max-warnings 0 on all 5 touched files → clean, exit 0.
  • npx vitest run --project unit src/lib/coachhelm/baseball/engine-v10.test.ts src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts27/27 passing.
  • Backward-compat sanity (not required by the file list, run anyway since loaders.ts is shared): full src/lib/coachhelm/baseball suite (57/57), plus the 4 external caller test files (action-baseline.test.ts, engine-run-coach-triage.test.ts, outcome-sweep-insight-resolve.test.ts, practice-effectiveness.test.ts, upload-stats-csv.test.ts) — all 39/39 passing unchanged.
  • src/lib/baseball/__tests__/stat-layer-contract.test.ts (business project) — ran before/after via git stash A/B on this exact branch: identical failure set both times (3 pre-existing offending files + 1 pre-existing stale entry, none touched by this PR — see Deferred below). This PR introduces zero new offenders and zero new stale entries to that contract test.

Deferred (pre-existing, out of scope for this chunk)

stat-layer-contract.test.ts was already red on batch/bbh-finish-0714 before this PR (confirmed via git stash):

  • New unlisted offenders: src/app/baseball/actions/__tests__/practice-effectiveness.test.ts, src/contracts/baseball/access/player-today-self-scope.test.ts, src/contracts/baseball/product-trust/player-today-honest-loop.test.ts reference a deprecated table without a manifest entry.
  • Stale entry: src/app/baseball/actions/insights.ts no longer references a deprecated table at all (it appears to have migrated in fix(baseball): migrate discover.ts/insights.ts actions onto withBaseballAction (#394) #819fix(baseball): migrate discover.ts/insights.ts actions onto withBaseballAction (#394) — without removing its manifest entry in that commit).

Neither is in this chunk's file list (loaders.ts, registry.ts, its two test files, the manifest); fixing them belongs to whichever chunk owns insights.ts/practice-effectiveness.ts/player-today.ts (Phase 2/3 in the #379 design) or a standalone manifest-hygiene fix. Flagging here so it isn't lost.

🤖 Generated with Claude Code

…ce-table + event-derived wiring

loaders.ts now honestly threads stat-layer provenance instead of hardcoding
baseball_player_stats everywhere, without changing any existing caller's
output:

- Per-row hittingSourceTable/pitchingSourceTable tags (set by the new
  normalizeBoxScoreBattingRow/normalizeBoxScorePitchingRow helpers) let a
  future caller that has normalized rows from the canonical
  baseball_box_score_batting/_pitching tables get an honest source_refs
  citation instead of the legacy default.
- An optional eventDerived input (built via eventDerivedVelocityFromMetrics
  from elite-stat-events.ts's DerivedMetric[] output) makes exit/pitch
  velocity metrics prefer real event-grain data over the legacy scalar
  columns, per-field, whenever it exists — never blended, never fabricated.
- Both inputs are additive/optional appended AFTER the existing nowIso
  param (preserved exactly, including its threading from #811), so
  engine-run.ts/outcome-sweep.ts/action-baseline.ts/practice-effectiveness.ts
  (Phase 4b/2, not touched here) keep working byte-for-byte unchanged —
  verified by running their existing test suites.

registry.ts's header doc comment no longer asserts baseball_player_stats/
baseball_player_aggregates as the metrics' only source, since loaders.ts can
now source some fields canonically; the file has zero remaining references
to the deprecated tables, so its stat-layer-manifest.ts grandfathered entry
is removed. registry.role-visibility.test.ts's source_refs fixture switches
to a canonical table name for the same reason and is also removed from the
manifest. engine-v10.test.ts gains new coverage for the loaders behavior
above (still legitimately cites baseball_player_stats to pin the
legacy-fallback path, so its manifest entry is kept, with an updated note).

Pre-existing, unrelated drift found but NOT fixed here (confirmed via
git-stash A/B on the base branch, present before this change): the
stat-layer-contract test already fails on 3 files outside this chunk
(practice-effectiveness.test.ts, player-today-self-scope.test.ts,
player-today-honest-loop.test.ts reference deprecated tables without a
manifest entry) and 1 stale entry (insights.ts, already migrated by #819,
never removed from the manifest). Flagged for a separate fix — out of
scope for the loaders/registry file list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
helmv3 Ignored Ignored Jul 15, 2026 8:16am

Request Review

@supabase

supabase Bot commented Jul 15, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project qmnssrrolpinvwjjnufo because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 382be660-7a66-4cdb-a097-ef7f806078d5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/379-engine-4a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

Phase 4a of the #379 stats-layer reconciliation: wires honest per-row source provenance and optional event-derived velocity overrides into loaders.ts, without touching any existing call site's output. All math is unchanged for the four unmigrated callers; the new inputs are strictly additive and backward-compatible.

  • loaders.ts gains two box-score normalizers, source-table routing helpers, and an event-derived velocity path that wins per-field over legacy scalars without blending.
  • registry.ts doc comment no longer asserts the deprecated flat tables as the only source; metric IDs, directions, and thresholds are untouched.
  • engine-v10.test.ts adds 6 new cases covering legacy-fallback preservation, per-field event-derived override, normalizer source threading, and absence handling.
  • stat-layer-manifest.ts correctly removes registry.ts and registry.role-visibility.test.ts entries while keeping loaders.ts and engine-v10.test.ts grandfathered.

Confidence Score: 4/5

Safe to merge for Phase 4a. No existing caller behavior changes. The two concerns are forward-looking footguns for Phase 4b adopters, not current defects.

The additive design is solid and tests thoroughly cover the new paths. Two concerns matter for Phase 4b: the source-table routing helpers pick the first tagged row via .find() so a mixed batch silently produces wrong provenance with no guard; and normalizeBoxScorePitchingRow produces rows without pitch_velocity, so a Phase 4b caller adopting the normalizer without wiring eventDerived loses velocity metrics silently. Neither is reachable today.

src/lib/coachhelm/baseball/loaders.ts — specifically hittingSourceTableFor/pitchingSourceTableFor (first-match assumption) and normalizeBoxScorePitchingRow (missing pitch_velocity callout in JSDoc).

Important Files Changed

Filename Overview
src/lib/coachhelm/baseball/loaders.ts Core change: adds optional hittingSourceTable/pitchingSourceTable tagging, two box-score normalizers, and an eventDerived velocity path. Backward-compatible; all existing callers unaffected. Two concerns: first-match source table resolution silently mislabels mixed batches, and normalizeBoxScorePitchingRow drops pitch_velocity silently.
src/lib/coachhelm/baseball/engine-v10.test.ts Adds new describe block with solid coverage. The metric() helper's source_refs table was changed to baseball_box_score_batting, creating a semantic mismatch with the new legacy-fallback test cases in the same file.
src/lib/coachhelm/baseball/metrics/registry.ts Doc-comment-only change: removes hardcoded deprecated table references from the header. No functional code changed; metric IDs, directions, fidelity levels, and thresholds are all untouched.
src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts Fixture table in the local ref() helper changed from baseball_player_stats to baseball_box_score_batting. The test's actual assertions (player_eligible boolean checks) are unaffected.
src/lib/baseball/stat-layer-manifest.ts Removes registry.ts and registry.role-visibility.test.ts entries, updates loaders.ts note, and keeps engine-v10.test.ts grandfathered. Manifest hygiene is correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Callers["Phase 4a Callers (unchanged)"]
        C1["engine-run.ts / outcome-sweep.ts / action-baseline.ts / practice-effectiveness.ts"]
    end
    subgraph Phase4b["Phase 4b Callers (future)"]
        C2["normalizeBoxScoreBattingRow() / normalizeBoxScorePitchingRow()"]
        C3["eventDerivedVelocityFromMetrics() from elite-stat-events.ts"]
    end
    subgraph Loader["loadPlayerMetrics / loadAllPlayerMetrics"]
        SR["hittingSourceTableFor / pitchingSourceTableFor"]
        EV_PATH["eventDerived velocity fields"]
        LEG_PATH["Legacy scalar fallback"]
    end
    subgraph Output["source_refs emitted"]
        T1["baseball_player_stats"]
        T2["baseball_box_score_batting / _pitching"]
        T3["baseball_batted_ball_events / baseball_pitch_events"]
    end
    C1 -->|"3-arg call, no tags"| Loader
    C2 -->|"tagged rows"| SR
    C3 -->|"EventDerivedVelocityInput"| EV_PATH
    SR -->|"no tag"| T1
    SR -->|"tag found"| T2
    EV_PATH -->|"value present"| T3
    EV_PATH -->|"null"| LEG_PATH
    LEG_PATH --> T1
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    subgraph Callers["Phase 4a Callers (unchanged)"]
        C1["engine-run.ts / outcome-sweep.ts / action-baseline.ts / practice-effectiveness.ts"]
    end
    subgraph Phase4b["Phase 4b Callers (future)"]
        C2["normalizeBoxScoreBattingRow() / normalizeBoxScorePitchingRow()"]
        C3["eventDerivedVelocityFromMetrics() from elite-stat-events.ts"]
    end
    subgraph Loader["loadPlayerMetrics / loadAllPlayerMetrics"]
        SR["hittingSourceTableFor / pitchingSourceTableFor"]
        EV_PATH["eventDerived velocity fields"]
        LEG_PATH["Legacy scalar fallback"]
    end
    subgraph Output["source_refs emitted"]
        T1["baseball_player_stats"]
        T2["baseball_box_score_batting / _pitching"]
        T3["baseball_batted_ball_events / baseball_pitch_events"]
    end
    C1 -->|"3-arg call, no tags"| Loader
    C2 -->|"tagged rows"| SR
    C3 -->|"EventDerivedVelocityInput"| EV_PATH
    SR -->|"no tag"| T1
    SR -->|"tag found"| T2
    EV_PATH -->|"value present"| T3
    EV_PATH -->|"null"| LEG_PATH
    LEG_PATH --> T1
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/lib/coachhelm/baseball/loaders.ts:455-459
**First-match source resolution silently mislabels mixed batches**

`hittingSourceTableFor` / `pitchingSourceTableFor` call `.find()` and return the first tagged row's table. When a Phase 4b caller accidentally passes a mixed batch — some rows normalized from `baseball_box_score_batting` (tagged), some from the legacy fetch (untagged) — every stat's `source_ref` will claim `baseball_box_score_batting`, even though a portion of the underlying data still came from `baseball_player_stats`. The "honest source refs" invariant the loader promises would be silently violated.

The helper's contract (all rows in a batch should share the same source) is nowhere documented or asserted. A cheap guard — logging a warning or throwing when more than one distinct `hittingSourceTable` is found among the rows — would surface the misuse immediately during Phase 4b migration rather than producing quietly wrong provenance in production.

### Issue 2 of 3
src/lib/coachhelm/baseball/loaders.ts:174-200
**`normalizeBoxScorePitchingRow` silently drops pitch velocity metrics**

The normalizer's input shape has no `pitch_velocity` field (correctly — `baseball_box_score_pitching` is a box-score aggregate, not pitch-event grain). As a result, rows produced by this function never populate `BoxScoreRow.pitch_velocity`, so `pvRows = pitchRows.filter(r => r.pitch_velocity != null)` will always be empty for a migrated caller. Unless `eventDerived.avgPitchVelocity` is explicitly supplied, `avg_pitch_velocity` and `max_pitch_velocity` are **silently absent** for that player.

A Phase 4b caller who adopts `normalizeBoxScorePitchingRow` without also wiring up `eventDerived` would see pitch velocity disappear from the loader output — different behavior from the legacy path — with no error or warning. The JSDoc doesn't mention this. A one-line note would prevent the surprise.

### Issue 3 of 3
src/lib/coachhelm/baseball/engine-v10.test.ts:68-72
**`metric()` fixture table contradicts the new legacy-fallback test cases**

The helper now hard-codes `table: 'baseball_box_score_batting'` as the source ref for inputs to the generator tests. But the new loader tests added in this same file (lines 139–145) explicitly verify that an unmigrated caller gets `'baseball_player_stats'`. A developer reading the file sees both: the generator fixtures claim canonical-table provenance, while the loader tests document the opposite for today's reality. Since generators don't validate the source ref table the inconsistency is harmless at runtime, but it makes the file's intent harder to read and could mislead someone debugging a provenance chain.

Reviews (1): Last reviewed commit: "fix(baseball): CoachHelm engine loaders/..." | Re-trigger Greptile

Comment on lines +455 to +459
function hittingSourceTableFor(rows: BoxScoreRow[]): string {
return rows.find((r) => r.hittingSourceTable)?.hittingSourceTable ?? LEGACY_STAT_TABLE;
}
function pitchingSourceTableFor(rows: BoxScoreRow[]): string {
return rows.find((r) => r.pitchingSourceTable)?.pitchingSourceTable ?? LEGACY_STAT_TABLE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 First-match source resolution silently mislabels mixed batches

hittingSourceTableFor / pitchingSourceTableFor call .find() and return the first tagged row's table. When a Phase 4b caller accidentally passes a mixed batch — some rows normalized from baseball_box_score_batting (tagged), some from the legacy fetch (untagged) — every stat's source_ref will claim baseball_box_score_batting, even though a portion of the underlying data still came from baseball_player_stats. The "honest source refs" invariant the loader promises would be silently violated.

The helper's contract (all rows in a batch should share the same source) is nowhere documented or asserted. A cheap guard — logging a warning or throwing when more than one distinct hittingSourceTable is found among the rows — would surface the misuse immediately during Phase 4b migration rather than producing quietly wrong provenance in production.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/coachhelm/baseball/loaders.ts
Line: 455-459

Comment:
**First-match source resolution silently mislabels mixed batches**

`hittingSourceTableFor` / `pitchingSourceTableFor` call `.find()` and return the first tagged row's table. When a Phase 4b caller accidentally passes a mixed batch — some rows normalized from `baseball_box_score_batting` (tagged), some from the legacy fetch (untagged) — every stat's `source_ref` will claim `baseball_box_score_batting`, even though a portion of the underlying data still came from `baseball_player_stats`. The "honest source refs" invariant the loader promises would be silently violated.

The helper's contract (all rows in a batch should share the same source) is nowhere documented or asserted. A cheap guard — logging a warning or throwing when more than one distinct `hittingSourceTable` is found among the rows — would surface the misuse immediately during Phase 4b migration rather than producing quietly wrong provenance in production.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +174 to +200
/** Normalize one `baseball_box_score_pitching` row into the loader's row shape. */
export function normalizeBoxScorePitchingRow(
row: {
id: string;
player_id: string;
ip: number;
bb: number;
k: number;
er: number;
pitch_count: number | null;
strikes: number | null;
},
gameDate: string | null,
): BoxScoreRow {
return {
id: row.id,
player_id: row.player_id,
stat_type: 'game',
session_date: gameDate,
innings_pitched: row.ip,
earned_runs: row.er,
walks_allowed: row.bb,
strikeouts_thrown: row.k,
pitches_thrown: row.pitch_count,
strikes_thrown: row.strikes,
pitchingSourceTable: 'baseball_box_score_pitching',
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 normalizeBoxScorePitchingRow silently drops pitch velocity metrics

The normalizer's input shape has no pitch_velocity field (correctly — baseball_box_score_pitching is a box-score aggregate, not pitch-event grain). As a result, rows produced by this function never populate BoxScoreRow.pitch_velocity, so pvRows = pitchRows.filter(r => r.pitch_velocity != null) will always be empty for a migrated caller. Unless eventDerived.avgPitchVelocity is explicitly supplied, avg_pitch_velocity and max_pitch_velocity are silently absent for that player.

A Phase 4b caller who adopts normalizeBoxScorePitchingRow without also wiring up eventDerived would see pitch velocity disappear from the loader output — different behavior from the legacy path — with no error or warning. The JSDoc doesn't mention this. A one-line note would prevent the surprise.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/coachhelm/baseball/loaders.ts
Line: 174-200

Comment:
**`normalizeBoxScorePitchingRow` silently drops pitch velocity metrics**

The normalizer's input shape has no `pitch_velocity` field (correctly — `baseball_box_score_pitching` is a box-score aggregate, not pitch-event grain). As a result, rows produced by this function never populate `BoxScoreRow.pitch_velocity`, so `pvRows = pitchRows.filter(r => r.pitch_velocity != null)` will always be empty for a migrated caller. Unless `eventDerived.avgPitchVelocity` is explicitly supplied, `avg_pitch_velocity` and `max_pitch_velocity` are **silently absent** for that player.

A Phase 4b caller who adopts `normalizeBoxScorePitchingRow` without also wiring up `eventDerived` would see pitch velocity disappear from the loader output — different behavior from the legacy path — with no error or warning. The JSDoc doesn't mention this. A one-line note would prevent the surprise.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

@njrini99-code
njrini99-code merged commit 0056bc0 into batch/bbh-finish-0714 Jul 15, 2026
30 of 34 checks passed
@njrini99-code
njrini99-code deleted the task/379-engine-4a branch July 15, 2026 08:26
njrini99-code added a commit that referenced this pull request Jul 15, 2026
…de changes) (#853)

Syncs 5 living docs + 1 memory file to actual tonight's reality on
batch/bbh-finish-0714 @ 0056bc0, independently re-verified (not copied
from PR claims) via grep/gh API/local test runs:

- PRODUCTION_READINESS_MISSION_2026-07-09.md: dated addendum (history kept
  intact) covering #792-#807 merged, discover-privacy P0 fixed+tested,
  29-surface Living-Annual migration done, tonight's batch merge state
  (#808 merged not "green-pending", #810 still open, #812-#841 + #851 on
  batch branch, #842-#850 still open), and the batch HEAD's 3 currently-red
  CI checks (Business contracts/Unit tests/Import-cycle ratchet).
- ui-migration-map.md + ui-migration-execution-plan.md: code-verified status
  headers — all 29 surfaces executed, Batch H (PR #820) done, zero
  isRedesignEnabled() forks remain under src/app/baseball or
  src/components/baseball.
- BASEBALLHELM_FEATURE_READINESS_MATRIX.md: ran
  check-readiness-matrix.ts (green before and after); upgraded Documents,
  Travel, Practice, Staff/Roles to ready and Practice Effectiveness to
  partial on real new test-coverage PRs (#822-#825); updated Player
  Today/Signals/Videos with tonight's #377 contract tests (#826) and #379
  Phase 4a progress (#851); rollup 10->14 ready. Re-ran the checker
  (route resolution + live owner-issue validation) clean after edits.
- BASEBALLHELM_PRODUCTION_VERDICT.md: reissued (old 2026-06-25 verdict kept
  as history below a new 2026-07-15 section) — honest "batch branch pending
  integration merge + CI" verdict, deferred-minors list, and the
  journey/pipeline vocabulary decision, #379 legacy-backfill scope,
  marketing-root (helm-website-ui/ vs src/app/page.tsx), and dual-wizard
  (ImportWizardClient vs EventImportWizard) open decisions, each grounded
  in a specific file/PR.
- memory/context/baseballhelm-features.md: corrected narrative lines now
  verifiably false (stale 2026-06-30 rollup counts, decision-room
  "unapplied migration"/#405-406 "open", pipeline "7 columns vs 5-stage
  enum", journey "UNVERIFIED source table", discover.ts profile_visibility
  omission, documents #393) — no AUTOGEN blocks in this file, none touched.

Gates: check:readiness-matrix exit 0 (route resolution + live GITHUB_TOKEN
owner-issue validation); readiness-matrix-routes.test.ts 204/204 passing;
no markdownlint config present in repo (skipped per task instructions).
Docs-only change; no product code touched.

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
njrini99-code added a commit that referenced this pull request Jul 15, 2026
…scoped precedence (supersedes #852) (#854)

* fix(baseball): CoachHelm engine Phase 4b — canonical stat reads for sweep/baseline/engine-run + deterministic import-quality clock (#379, #811 residual)

Reader migration (#379 Phase 4b, the highest-blast-radius chunk):
- NEW src/lib/baseball/coachhelm/engine-stat-rows.ts — the ONE consolidated
  per-session stat-row read for the engine. Prefers canonical
  baseball_box_score_batting/_pitching rows (normalized onto the loader shape
  via #851's normalizeBoxScoreBattingRow/normalizeBoxScorePitchingRow, with
  session_date joined from baseball_games and source-table provenance tags),
  reconciled over legacy baseball_player_stats rows per the #379 precedence
  rule: canonical rows replace a player's legacy GAME rows outright (never
  blended — the #827 seed writes the same games into both layers), legacy
  practice/other rows always survive (practice carve-out), and a player with
  zero canonical rows keeps full legacy history (fallback tier). Canonical-side
  read failures degrade all-or-nothing to the legacy pool; a legacy read
  failure remains the callers' hard error. All reads paginate past the
  PostgREST 1000-row cap with stable ordering.
- outcome-sweep.ts / action-baseline.ts / engine-run.ts all swap their direct
  baseball_player_stats reads for the shared helper, so baseline capture, the
  outcome sweep, and the engine run measure the SAME reconciled pool
  (apples-to-apples did-it-move). action-baseline's old single-page
  .limit(1000) read is replaced by the paginated shared read.

#811 residual (deterministic engine clock):
- BaseballV10EngineInputs gains an optional now (ISO); engine-run threads its
  nowIso through it; importQualityGenerator's 14-day recency window computes
  from the caller-supplied nowIso instead of raw Date.now() (default preserves
  real-time behavior for non-engine callers). New generators/v10.test.ts pins
  the window against a fixed 2020 clock; engine-run-helm-lifting.test.ts pins
  that runBaseballEngineCore passes its own nowIso end-to-end.

Provenance labels:
- generators/index.ts driver() last-resort fallback label no longer hardcodes
  the deprecated table (loaders now cite the real per-row table); v10.ts's
  practice-effectiveness cite stays deliberately (its feeder still reads
  legacy practice rows) with an explanatory comment.

Manifest (stat-layer contract kept green in both directions for this chunk):
- Removed migrated entries: outcome-sweep.ts, engine-run.ts,
  action-baseline.ts, generators/index.ts, ai-policy-enforcement.test.ts,
  signal-from-insight.test.ts (fixtures moved to canonical table names).
- Added: engine-stat-rows.ts + its test (the one allowed legacy-fallback read).
- Updated notes: loaders.ts, generators/v10.ts, effectiveness/engine.ts and
  operational-rule-engine.ts (both reviewed, deliberately deferred — their
  cites are honest while their feeders still read layer 1), plus the three
  engine test entries now pinning the fallback tier.

Tests: engine-stat-rows.test.ts pins the precedence rule directly;
action-baseline.test.ts + outcome-sweep-insight-resolve.test.ts gain
canonical-preferred, never-blended coverage alongside the existing
legacy-fallback pins.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): scope engine-stat-rows precedence to (player, date), not player alone (#379, #852 review fix)

The #379 exclusion rule dropped ALL of a player's legacy stat_type='game'
rows once they had ANY canonical box-score row, even for games with no
canonical counterpart — a mid-season box-score import start would silently
erase that player's earlier legacy-logged games from every engine caller
(engine-run, outcome-sweep, action-baseline), shrinking sample_n and
flipping confidence/verdicts. baseball_player_stats has no game_id, so we
now correlate on the resolved canonical game date (truncated to YYYY-MM-DD
on both sides) instead: a legacy game row is dropped only when that same
player has canonical coverage on that exact calendar day: a same-day
heuristic, not a guaranteed game-identity match, since there's no FK to
lean on (documented in the module comment as an accepted double-header
collision risk).

Existing precedence tests encoded the bug: their legacy-row fixture dates
never matched the canonical game dates, yet still asserted full drop —
only possible under the old player-scoped exclusion. Realigned those
fixture dates to same-day overlap (preserving each test's 100%-coverage
intent) and added a mixed-coverage case (3 legacy-only + 2 canonical -> 5
rows survive) plus a same-day-collision case (legacy row on a
canonically-covered date -> dropped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
njrini99-code added a commit that referenced this pull request Jul 15, 2026
…-sweep/action-baseline (#852 residual) (#864)

* baseball(engine): wire event-derived velocity into engine-run/outcome-sweep/action-baseline (#852 residual)

Box-score-migrated players had NO velocity metrics: their legacy
exit_velocity/pitch_velocity scalar is dropped alongside superseded legacy
GAME rows (engine-stat-rows.ts rule 1), and the canonical box-score tables
carry no velocity columns at all. loaders.ts's eventDerived hook (#851)
already threaded a per-field event-layer override into loadPlayerMetrics,
but nothing called it.

Adds src/lib/baseball/coachhelm/engine-event-derived.ts: a team-scoped,
paginated read of baseball_pitch_events/baseball_batted_ball_events (#813
superseded-row filter) plus a pure per-player reducer that reuses
elite-stat-events.ts's real buildHitterMetrics/buildPitcherMetrics +
loaders.ts's eventDerivedVelocityFromMetrics -- never a second, drifting
"average exit velocity" implementation. All-or-nothing degrade on read
failure, mirroring engine-stat-rows.ts's own honesty rule.

Wires it into all three engine callers:
- engine-run.ts: full-history event pool -> loadAllPlayerMetrics.
- outcome-sweep.ts: event rows filtered to the SAME per-action after-window
  as the box-score read, so a pre-action event never counts toward
  did-it-move measurement.
- action-baseline.ts: full-history event pool -> the baseline capture.

Tests: pure aggregation (mixed hitter/pitcher, zero-event absence,
supersede filter, all-or-nothing degrade) plus per-caller wiring tests
(event wins over legacy scalar for the same player; a zero-event player
keeps their legacy velocity; event-read failure degrades every player to
legacy). Extends stat-layer-manifest.ts's grandfathered-consumer allowlist
for the new fixture files (legacy baseball_player_stats rows are the
fallback pin, not staleness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): bound velocity event read to player scope + fix sampleSize honesty (PR #864 fix-first)

Two adversarial-review criticals on #864:

1. buildActionOutcomeSeed (action-baseline.ts) fired a TEAM-WIDE, unbounded,
   player-unscoped read of the entire pitch/batted-ball event history on
   every coach "convert to action" click, just to resolve ONE player's
   velocity scalar. loadEngineEventRows now takes an optional `playerIds`
   scope (`.in('pitcher_id'|'batter_id', playerIds)`, mirroring
   loadEngineStatRows's own `.in('player_id', playerIds)` idiom) — the
   single-player caller passes `[playerId]`; engine-run/outcome-sweep now
   pass their own already-computed roster/todo player-id lists instead of
   reading the whole team's history.

2. avg_exit_velocity's sampleSize was `bbCount` (every batted ball) instead
   of the count of rows that actually carried a non-null exit_velocity
   reading — inflating the honesty gate for any team whose batted-ball
   capture doesn't always log a radar reading. Fixed to
   `battedBalls.filter(b => b.exit_velocity != null).length`, and applied
   the same fix to the sibling avg_launch_angle metric (identical bug,
   same line shape). Pitcher avg_velocity was already correct.

Tests: pin the DB-level player scoping (loadEngineEventRows + a
buildActionOutcomeSeed integration check), and pin the sampleSize fix (10
batted balls / 4 readings -> sampleSize 4; independent launch_angle gating;
hard_hit_rate's bbCount-based denominator unaffected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
njrini99-code added a commit that referenced this pull request Jul 16, 2026
…al-audit infra, de-vibe wave 2a, public-page motion fix (#868)

* devibe: remove dead files — knip batch 1/2 (mode-toggle, notifications, insight-actions) (#858)

Verified dead via grep (import path + symbol name + next/dynamic scan),
then git rm. No consumers found in src/, no test coverage, no dynamic
imports referencing any of these paths.

- src/components/baseball/coach/ModeToggle.tsx — exports JUCOModeToggle,
  zero importers repo-wide. Only referenced from stale docs (PHASE_5_JUCO_COACH.md,
  .helm/ACTIONS.md) describing a wiring into src/components/layout/header.tsx,
  which no longer exists.
- src/components/layout/mode-toggle.tsx — exports ModeToggle/Mode, its only
  consumer was the dead file above.
- src/components/features/notification-center.tsx — duplicate/legacy
  NotificationCenter; the live one is src/components/golf/calendar/NotificationCenter.tsx.
  .taskmaster/docs/current-state.md already flagged it "Exists but not used".
- src/hooks/use-notifications.ts — duplicate/legacy useNotifications; the live
  hook is src/hooks/useNotifications.ts (capital N), consumed by the real
  NotificationCenter.
- src/components/golf/coachhelm/insights/{InsightBulkActions,InsightExportModal,
  InsightFiltersPanel,InsightSearchBar}.tsx — not exported from the insights/
  barrel (index.ts only re-exports PlayerFocusAreas/InsightsFeed/InsightListView
  per its "Wave 1A" comment), zero direct importers, no next/dynamic references.
- src/lib/baseball/lifting/use-live-set-sync.ts — exports useLiveSetSync, zero
  importers; only mentioned in docs/audits (planned-but-never-wired).

Gates: typecheck clean, check-cycles clean (33 known cycles, none new), no
test files reference any of these paths.

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* devibe: remove dead files — knip batch 2/2 (golf/travel legacy, soreness barrel, lift-programs) (#859)

Verified dead via grep (import path + symbol name + next/dynamic scan),
then git rm.

- src/components/golf/travel/{ExpenseForm,ExpenseList,ExpenseSummary,index}.ts(x)
  — legacy pre-Fairway components. Superseded by src/components/fairway/pages/travel/
  Fairway{ExpenseForm,ExpenseList,ExpenseSummary}.tsx, whose own header comments
  say they're re-skins of "the legacy golf/travel ExpenseList/ExpenseSummary" —
  i.e. the legacy files are explicitly documented as replaced. Zero live importers
  (grep for the barrel path and each symbol name comes back empty outside the
  legacy files themselves).
- src/components/lifting/soreness/index.ts — barrel; zero importers (every other
  file in the same directory — BodySilhouetteFront, SorenessCheckCard,
  SorenessBodyMap, HighPrioritySorenessList, SorenessScheduleBuilder — IS
  imported directly by app code, just never through this barrel).
- src/components/lifting/soreness/SorenessComplianceBoard.tsx,
  TeamSorenessHeatmap.tsx — only referenced from the dead barrel above; no
  direct importers.
- src/lib/baseball/read-models/lift-programs.ts — exports getLiftProgramList/
  getLiftProgramTree/getAssignContext. The live /performance/programs/[programId]
  page defines its own local getAssignContext (duplicated, not imported from
  here) — confirms this read-model was built but never wired in.

Gates: typecheck clean, check-cycles clean (33 known cycles, none new).
`grep` false-positive check: src/app/golf/actions/__tests__/travel.test.ts
matches "ExpenseSummary" only via the substring in getExpenseSummary() (a
server action, unrelated file) — ran that suite standalone to confirm
(128 passed, 4 skipped, unaffected).

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* devibe: remove orphaned root scaffolding (.taskmaster, .full-stack-feature, stray App Store Connect snapshots) (#860)

- .taskmaster/ (9 tracked files: README, config.json, docs/current-state.md,
  docs/feature-checklist.md, docs/prd.txt, logs/.gitkeep, state.json,
  tasks/tasks.json, templates/task-template.json) — task-master scaffolding
  from an abandoned tool integration. Only appears elsewhere as ignore-list
  entries (.gitignore:76-77), never read by any script/workflow/package.json
  script. Zero functional references.
- .full-stack-feature/ (2 tracked files: 01-requirements.md, state.json) —
  same pattern: only appears as ignore-list entries across .gitignore,
  .coderabbitignore, .coderabbit.yaml, .vercelignore, .greptile/config.json,
  .greptile/rules.md (all just telling other tools to skip the directory).
  Zero functional references.
- full-snapshot.yml, full-snapshot2.yml, app-info-snapshot.yml,
  age-ratings-snapshot.yml — accessibility-tree/DOM snapshots of the App
  Store Connect web UI (not fastlane config — there is no fastlane/ directory
  anywhere in this repo, which uses Xcode Cloud, not fastlane). Zero script
  or CI references (grepped scripts/, tools/, .github/, .circleci/ — nothing
  reads these paths). The one doc mention
  (docs/operations/2026-05-28-coderabbit-fails-investigation.md) explicitly
  calls age-ratings-snapshot.yml "INHERITED NOISE" causing ~200 yamllint
  indentation errors and recommends "delete it if it's truly unused" — it is.
  review-gate.yml's yamllint job only lints *changed* files in a PR diff, so
  these aren't continuously failing CI, but they're pure accidental commits
  (browser-automation output) with zero purpose in the repo.
- context7.json — does not exist (only context7.json.example is tracked;
  the real context7.json was already removed in a prior commit
  6a9b565 "fix(security): stop tracking context7.json (contained leaked API
  key)"). Nothing to do here.

Gates: typecheck clean, check-cycles clean (33 known cycles, none new).

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* devibe: console triage — remove debug-leftover console.log in use-service-worker (#861)

Audited the 77 console.log/debug/warn call sites in prod src (excluding
tests). Two mechanisms make almost all of them deliberate, not vibe-coded
leftovers, and this PR documents why nearly everything was kept:

- next.config.mjs compiler.removeConsole strips console.log AND
  console.debug from production builds, excluding only 'error'/'warn'.
  So every console.log/.debug call is already dev-only/no-op in prod.
- src/instrumentation.ts + src/instrumentation-client.ts both configure
  Sentry.consoleLoggingIntegration({ levels: ['log','warn','error'] }) —
  console.warn is the established, load-bearing structured-logging idiom
  in this codebase (forwarded to Sentry Explore → Logs), which is exactly
  why admin-tracer-data.ts has an explicit comment: "console.warn used
  (not console.log) because production build strips console.log."

Reviewed every one of the 48 console.warn and 8 console.debug call sites
individually: every single one has either an explicit comment justifying
the log level (e.g. insight-delivery.ts's transient-fetch debug downgrade,
useAdminPresence.ts's `if (process.env.NODE_ENV !== 'production')`-gated
join/leave debug logs, pattern-miner.ts's documented severity policy,
admin-logger.ts's PGRST205 once-only warn) or is a genuine production
security/error signal (auth rate-limiting, unauthorized message/team
actions, fetch-failure fallbacks). None were genuine leftovers — all kept
as-is, no logger-idiom conversion performed (see below).

**Deleted** (1 file, 8 statements): src/hooks/golf/use-service-worker.ts
— 8 console.log calls tracing every SW lifecycle branch (register
no-op, already-registered, registered, unregistered, update complete,
sync unsupported, sync registered, no active worker to message, message
received). Unlike every kept call site above, these had (a) no
explanatory comment, (b) no dev-only guard, (c) duplicate state already
exposed via the hook's own return value (`status`/`isRegistered`/
`hasUpdate`), and (d) trace literally every branch including plain early
returns — the classic "log every branch while debugging a tricky SW bug"
pattern (see memory: dev-SW false-offline investigation) never cleaned
up. The 5 console.error calls in this same file's catch blocks are
untouched (KEEP per the task rule).

**Logger-idiom conversion**: grepped for a logger util first
(src/lib/admin-logger.ts, server-error-logger.ts, error-logging.ts exist)
— none is a general-purpose console.warn replacement; they're
purpose-built for the admin_events audit trail / Sentry error
classification, and console.warn already IS the repo's structured-log
idiom for this class of signal (per the Sentry consoleLoggingIntegration
wiring above). Converting would be redundant double-logging and risk
semantic changes (async logger calls dropped into sync catch blocks) for
no observability gain, so no conversions were made — warns left as-is,
per the "if none, leave warns" instruction.

Gates: typecheck clean, eslint --max-warnings 0 on the touched file clean,
check-cycles clean (33 known cycles, none new). No test file covers this
hook (grepped for use-service-worker in *.test.*/*.spec.* — zero hits).

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Build the /baseball public marketing page (was a bare redirect) (#865)

Signed-out visitors used to get bounced straight to /baseball/login with
zero context; they now see a real front door — hero, four editorial
feature sections (roster/team-ops, stats center, recruiting pipeline,
player passport) composed from the Living Annual kit in ghost/placeholder
state (no fabricated screenshots or invented player data), and an honest
CTA row (Sign in / Create a program / Join with a code). Signed-in
visitors keep the exact prior redirect-to-dashboard behavior.

- src/app/baseball/page.tsx: rewritten from a bare redirect into the full
  marketing page; auth check now only fires the redirect when a session
  exists.
- src/components/baseball/marketing/BaseballMarketingMotionScope.tsx: new
  tiny 'use client' LazyMotion wrapper — the Living Annual atoms used here
  (RuledStatLine/Masthead/HairlineRule/GradeStamp) never transition off
  their hidden variant without a loaded feature bundle, and the page
  itself stays a Server Component (async session check + redirect), so
  this is the one client boundary.
- src/app/baseball/join/page.tsx: new — the "Join with a code" CTA needed
  a real destination; only the dynamic /baseball/join/[code] existed.
  Mirrors GolfHelm's /golf/join code-entry page, themed in the Living
  Annual paper/ink system instead of golf's glass-orb auth chrome.
- src/components/landing/Footer.tsx: generalized the shared cross-product
  footer's tagline off golf-only wording ("college golf") since it now
  also renders under a BaseballHelm hero.
- src/app/baseball/__tests__/page.test.tsx: pins the redirect/no-redirect
  branching (coach session, player session, signed-out).

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Fix invisible names/numerals on public baseball profile pages (no LazyMotion ancestor) (#866)

team/[id], player/[id] (via PlayerProfileClient), program/[id], and
packet/[token] sit in the (public) route group, whose layout was a bare
`<>{children}</>` — no LazyMotion anywhere upstream. team/[id] and
PlayerProfileClient render Living Annual `m`-based atoms (Masthead,
RuledStatLine, HairlineRule) directly; their `inkSettles`/`rulesDraw`
entrance variants start at `hidden` (opacity: 0 / scaleX: 0) and only
animate to `visible` once framer-motion's feature bundle is loaded via a
`LazyMotion` ancestor. Without one, an `m.*` component's AnimationFeature
never mounts, so the hidden variant is terminal for any visitor without
`prefers-reduced-motion` on — player/team names and stat numerals stayed
invisible on these live public recruiting pages.

Adds PublicMotionScope (mirrors the existing AdminMotionProvider /
`(dashboard)/dashboard/template.tsx` pattern already used elsewhere in the
repo) and mounts it from `(public)/layout.tsx`, which stays a Server
Component — the LazyMotion boundary lives in the client child.

Verified via a real (unmocked) framer-motion render test: Masthead's
surname text is measurably opacity: 0 forever with no wrapper, and
measurably transitions off 0 once PublicMotionScope loads its feature
bundle — the same computed-opacity check `toBeVisible()` uses, so it
reproduces the actual bug and the actual fix rather than a mocked stand-in.

program/[id] and packet/[token] don't currently render any Living Annual
`m` atoms directly (packet's ScoutPacketView already carries its own
LazyMotion) — the shared layout-level provider covers them defensively
against regression as those pages grow.

PR #865 (open, targets this same base) adds a near-identical
BaseballMarketingMotionScope for the separate /baseball marketing root and
explicitly flagged this (public) route group gap out of its own scope;
this PR is the fix for that flagged gap. Not touching #865's files — noted
in the PR body that the two wrappers could be consolidated into one shared
component later.

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Add production visual-audit screenshot crawl (GHA, manual-only) (#867)

New e2e/visual-audit.spec.ts mirrors baseball-route-crawler.spec.ts's proven
live-DOM nav discovery (FairwaySidebar + hub-sub-nav <nav> links) and
best-effort public-sample-link discovery, but captures full-page screenshots
at phone (390x844) and desktop (1440x900) viewports for every discovered
coach/player route plus signed-out publics, instead of asserting route
health. Screenshots are data capture, not assertions — the spec only fails
on a login failure or a total navigation failure. Gated behind
VISUAL_AUDIT=1 (test.skip otherwise); playwright.config.ts's chromium
project now ignores it and baseball-coach/baseball-player now match it, so
it never runs in the ordinary e2e lane and playwright.yml/ci.yml (which
name their spec files explicitly) never pick it up.

New .github/workflows/visual-audit.yml runs it via workflow_dispatch against
a chosen base_url (default prod), --project=baseball-coach
--project=baseball-player only — verified against the installed Playwright
runner source that this also runs the `setup` project's full baseball auth
(both roles) as a dependency, without needing an explicit --project=setup,
and without ever touching Golf's auth.setup.ts. Uploads
test-results/visual-audit as visual-audit-<run_number>, if: always().


Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* db(baseball): write #379 legacy stats backfill migration (pending Nick's go) (#862)

* db(baseball): write #379 legacy stats backfill migration (pending Nick's go)

One-time, NOT-APPLIED migration that copies legacy baseball_player_stats
'game' rows into baseball_box_score_batting/_pitching + synthesizes shared
baseball_games rows, scoped to teams with ZERO existing box-score data (teams
already on the box-score adapter path are never touched). Deterministic ids
(SHA-1, RFC4122-v5-shaped, own namespace) mirror #827's
scripts/seed-baseball-stats.mjs detId() pattern so re-applying is a no-op and
rollback can recompute — not just look up — exactly which rows are ours.
Copy-only: legacy rows are never mutated. Deliberately skips
recalculate_baseball_season_stats() to avoid clobbering any pre-existing
season_totals-imported baseline on baseball_player_season_stats — documented
as an opt-in follow-up instead.

Exercised end-to-end against a disposable local Postgres 16 instance (schema
mirrored from the real migrations, never any shared project) covering a
two-way partial-innings player, a duplicate-row collision, an
already-box-score team (excluded), and a pre-existing-scheduled-game
collision (date skipped) — verified idempotent re-run and a dry-run rollback
recompute+delete. See docs/baseball/legacy-backfill-runbook.md for the
check-first queries, apply steps, and rollback recipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): make #379 backfill's season-stats safety story true, not just written

Adversarial review on PR #862 found the migration's core safety claim false:
recalculate_baseball_season_stats() is described as a deliberate, manual,
opt-in, per-team step, but the already-shipped save_baseball_full_box_score
RPC calls it automatically on every ordinary box-score save. Since the
backfilled games carry their real historical game_date (plausibly within the
current season year for teams whose whole history predates #827), the very
next normal game entry for an overlapping player would silently overwrite
baseball_player_season_stats -- including any pre-existing season_totals
baseline -- with no opt-in and no signoff.

Fix, verified against a disposable local Postgres 16 instance (never any
shared Supabase project):

- Migration: add Step 4, seeding baseball_player_season_stats for exactly the
  (player_id, team_id, season_year) triples the migration's own box-score
  rows touch, using the identical aggregation/rate formulas
  recalculate_baseball_season_stats() uses -- guarded by
  ON CONFLICT ... DO NOTHING so a pre-existing row (e.g. a season_totals
  baseline) is never touched, preserving copy-only/additive-only/idempotent.
  Where no row existed, the eventual live recalc now lands on the same
  numbers already seeded (a no-op, not a surprise).
- Runbook: replace the "deliberately out of scope" framing with the true
  story, add a pre-flight query that surfaces exactly which triples still
  carry pre-existing-baseline risk (Nick must review before applying), and
  add a diff-based season-stats rollback procedure since DO NOTHING rows
  have no deterministic id to recompute against.

Locally reproduced the exact scenario the review described (a fresh ordinary
game save via the real, unmodified RPC): the seeded player's row extended
cleanly with correct math; the pre-existing baseline player's row was
silently overwritten by the (unmodified) live RPC, exactly as newly
documented -- confirming the fix and the doc are both now accurate.

File remains WRITE-ONLY / NOT APPLIED pending Nick's go-ahead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* baseball(engine): wire event-derived velocity into engine-run/outcome-sweep/action-baseline (#852 residual) (#864)

* baseball(engine): wire event-derived velocity into engine-run/outcome-sweep/action-baseline (#852 residual)

Box-score-migrated players had NO velocity metrics: their legacy
exit_velocity/pitch_velocity scalar is dropped alongside superseded legacy
GAME rows (engine-stat-rows.ts rule 1), and the canonical box-score tables
carry no velocity columns at all. loaders.ts's eventDerived hook (#851)
already threaded a per-field event-layer override into loadPlayerMetrics,
but nothing called it.

Adds src/lib/baseball/coachhelm/engine-event-derived.ts: a team-scoped,
paginated read of baseball_pitch_events/baseball_batted_ball_events (#813
superseded-row filter) plus a pure per-player reducer that reuses
elite-stat-events.ts's real buildHitterMetrics/buildPitcherMetrics +
loaders.ts's eventDerivedVelocityFromMetrics -- never a second, drifting
"average exit velocity" implementation. All-or-nothing degrade on read
failure, mirroring engine-stat-rows.ts's own honesty rule.

Wires it into all three engine callers:
- engine-run.ts: full-history event pool -> loadAllPlayerMetrics.
- outcome-sweep.ts: event rows filtered to the SAME per-action after-window
  as the box-score read, so a pre-action event never counts toward
  did-it-move measurement.
- action-baseline.ts: full-history event pool -> the baseline capture.

Tests: pure aggregation (mixed hitter/pitcher, zero-event absence,
supersede filter, all-or-nothing degrade) plus per-caller wiring tests
(event wins over legacy scalar for the same player; a zero-event player
keeps their legacy velocity; event-read failure degrades every player to
legacy). Extends stat-layer-manifest.ts's grandfathered-consumer allowlist
for the new fixture files (legacy baseball_player_stats rows are the
fallback pin, not staleness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): bound velocity event read to player scope + fix sampleSize honesty (PR #864 fix-first)

Two adversarial-review criticals on #864:

1. buildActionOutcomeSeed (action-baseline.ts) fired a TEAM-WIDE, unbounded,
   player-unscoped read of the entire pitch/batted-ball event history on
   every coach "convert to action" click, just to resolve ONE player's
   velocity scalar. loadEngineEventRows now takes an optional `playerIds`
   scope (`.in('pitcher_id'|'batter_id', playerIds)`, mirroring
   loadEngineStatRows's own `.in('player_id', playerIds)` idiom) — the
   single-player caller passes `[playerId]`; engine-run/outcome-sweep now
   pass their own already-computed roster/todo player-id lists instead of
   reading the whole team's history.

2. avg_exit_velocity's sampleSize was `bbCount` (every batted ball) instead
   of the count of rows that actually carried a non-null exit_velocity
   reading — inflating the honesty gate for any team whose batted-ball
   capture doesn't always log a radar reading. Fixed to
   `battedBalls.filter(b => b.exit_velocity != null).length`, and applied
   the same fix to the sibling avg_launch_angle metric (identical bug,
   same line shape). Pitcher avg_velocity was already correct.

Tests: pin the DB-level player scoping (loadEngineEventRows + a
buildActionOutcomeSeed integration check), and pin the sampleSize fix (10
batted balls / 4 readings -> sampleSize 4; independent launch_angle gating;
hard_hit_rate's bbCount-based denominator unaffected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Consolidate stats-upload wizard into Import Center (canonical) (#863)

* Consolidate stats-upload wizard into Import Center (canonical)

Audited both wizards end-to-end (§3.11 decision: Import Center is
canonical). Ported the two real capability gaps before retiring the
legacy path — everything else (atomic save_baseball_full_box_score RPC,
player-match corrections, dedup/provenance/rollback) was already covered
by Import Center's commitImport pipeline, so nothing else needed porting:

- ImportWizardClient: added a "Quick box score" entry point on the choose
  step (preselects game_box_score + jumps straight to Upload) plus
  drag-and-drop onto the dropzone and a sample-values data-preview table
  on the detect step — the legacy wizard's two capabilities Import Center
  didn't have. No server-action signatures changed.
- /dashboard/stats/upload is now a pure redirect into /dashboard/import,
  mirroring the stats -> stats-center legacy-redirect shim idiom. Sibling
  error.tsx/loading.tsx removed (that idiom has neither).
- Retired the now-fully-orphaned StatsUploadClient/UploadHistory
  components (only ever imported by the old page).
- Repointed the two in-app links that still pointed at the legacy route
  (Command Center's "Upload stats", Stats Center's header) straight at
  Import Center, and dropped Stats Center's redundant "Upload" button
  (Import Center already sat right next to it, same destination).
- Test migration: extended settings-aliases-and-legacy-redirects.test.ts
  with the new shim, added ImportWizardClient.quick-box-score.test.tsx for
  the two ported capabilities, and updated the e2e assertion that pinned
  the retired wizard's UI strings to assert the redirect instead.

nav-registry.ts (frozen) still lists /baseball/dashboard/stats/upload in
stats-center's matchPrefixes and STAFF_CAPABILITY_ROUTES/GUARD_ALLOWLIST
still gate it at can_manage_stats — both harmless now (a plain redirect
page, still resolves on disk, destination re-enforces can_manage_imports
itself) but flagging for the orchestrator in case a follow-up wants them
tidied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* Fix wizard-consolidation capability lockout + restore upload history (PR #863)

Adversarial review (FIX_FIRST) flagged two criticals in the stats-upload ->
Import Center consolidation:

1. CAPABILITY LOCKOUT — the /stats/upload redirect shim + the two repointed
   CTAs sent every viewer straight at Import Center's can_manage_imports gate,
   locking out every default staff role that holds can_manage_stats but not
   can_manage_imports (assistant/pitching/hitting/catching/defensive/strength
   coach — 6 of 11 canonical BASEBALL_STAFF_ROLE_PRESETS). Those roles could
   reach and interact with the old wizard before this consolidation.

   Fix: /stats/upload now branches on capability instead of redirecting
   unconditionally. can_manage_imports staff still forward to the full Import
   Center; can_manage_stats-only staff get the SAME ImportWizardClient
   rendered inline, restricted to the "Quick box score" entry point
   (new quickEntryOnly prop — skips the choose step and hides the "change
   data shape" affordance, no way to reach the full shape picker/event-level
   mode/source registry/rollback reserved for can_manage_imports staff).
   Middleware's STAFF_CAPABILITY_ROUTES already allowlists this exact route
   at can_manage_stats, so no middleware/nav-registry contract change was
   needed. Command Center's "Upload stats" and Stats Center's two CTAs are
   repointed from /dashboard/import back to /dashboard/stats/upload so every
   entry point resolves through the capability-aware router.

2. UPLOAD HISTORY DELETED — UploadHistory.tsx was the only surface reading
   baseball_stat_uploads (filename/status/processed counts); its deletion
   left every pre-consolidation upload record permanently unviewable.

   Fix: ported a read-only "Legacy uploads" section into ImportWizardClient
   (Living Annual idiom: Eyebrow/HairlineRule/EditorsLetter honest empty
   state, matching the existing "Recent imports" section), backed by
   getRecentUploads — an existing, already-demoSafe, already-team-scoped
   server action with zero prior callers. No server-action signature
   changes. Wired into both the full Import Center page and the new
   capability-aware /stats/upload entry point.

Also extracted the roster-for-matching query (previously inlined in
import/page.tsx) into a shared src/lib/baseball/import-roster.ts helper so
both pages load player-matching data identically instead of drifting.

Gates: typecheck clean, eslint --max-warnings 0 clean on all touched files,
targeted + broader baseball vitest suites green (1178 tests), check-cycles
clean (33 known cycles, none new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(stats-center): route import entry points by viewer capability

The two Import Center entry points (header action + empty-state CTA) sent
everyone through the /stats/upload shim, whose middleware gate is
can_manage_stats — bouncing import-capable-but-not-stats staff (e.g. the
director_ops preset) off middleware before the shim's own capability branch
could forward them. The page now computes can_manage_imports server-side
(same helper the shim branches on) and import-capable viewers go straight to
/dashboard/import; everyone else keeps the shim path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball-import): authorize stats-only staff for box-score import commit/preview (PR #863 round-4)

previewImport/commitImport were hard-gated to can_manage_imports
unconditionally, so the quickEntryOnly inline wizard at /stats/upload
(rendered for the 6 can_manage_stats-only staff presets) let a
stats-only coach fill out the whole form and then fail server-side on
submit. Pre-consolidation, stats-only staff could upload box scores via
the legacy wizard, so restore that: a 'game_box_score' request may now
be authorized by can_manage_imports OR can_manage_stats; every other
shape (season_totals, event_log, or omitted) keeps the original
can_manage_imports-only gate.

- with-baseball-action.ts: requiredCapability now also accepts a
  readonly array (ANY-of) or a resolver function of the action's own
  args, resolved once before AUTH so tags/metadata and enforcement can
  never disagree. Single-capability call sites (~60 existing) resolve
  to a one-element list and behave byte-identically to before.
- imports.ts: previewImport gained an optional dataShape field
  (mirroring CommitImportArgs.dataShape) so the same shape-conditional
  gate applies at preview time too; both actions resolve the OR-gate
  from the exact field applyImportPlan uses for canonical-table
  routing, so the auth decision and the write decision can never
  diverge.
- ImportWizardClient.tsx: pass dataShape through to previewImport, and
  hide the Upload step's "Back to choose" button for quickEntryOnly
  viewers (it routed to the full shape picker Import Center reserves
  for can_manage_imports staff).
- New suite (imports-capability-shape-gate.test.ts) exercises the real
  withBaseballAction/capabilities wiring (not a passthrough mock) to
  prove: stats-only + game_box_score authorizes and actually writes;
  stats-only + season_totals still throws BaseballCapabilityError with
  zero side effects; no-capability staff still denied; imports-only
  staff unchanged across every shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* ci(visual-audit): two spaces before inline version comments (yamllint strict)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(migration): qualify digest() as extensions.digest — pgcrypto is not in public

The 42883 failure reproduced on the CI fresh-stack replay and would have
occurred identically on prod at apply time: pgcrypto lives in the
extensions schema in both environments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* db(baseball): manifest-based rollback + concurrency lock for #379 backfill (CodeRabbit #868)

- Copy-only summary now lists Step 4's baseball_player_season_stats write (finding 1).
- Add permanent, service-role-only baseball_legacy_backfill_manifest ledger
  (RLS enabled, anon/authenticated revoked); every Step 1-4 INSERT records its
  own RETURNING rows into it, same transaction, tagged with a run_tag. Rollback
  now joins against the manifest instead of recomputing deterministic ids from
  current (possibly-changed) baseball_player_stats, and the runbook's rollback
  + season-stats-rollback sections are rewritten around manifest-join DELETEs.
  Verified recalculate_baseball_season_stats() does a full from-scratch
  rebuild (not an incremental merge) before writing the "safe to delete"
  rollback caveat (finding 2).
- Take an explicit LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE on all 5
  read/written tables before the eligibility snapshot; runbook gains an apply-
  window note. Confirmed SHARE ROW EXCLUSIVE cannot self-conflict with this
  migration's own later INSERTs (finding 9).
- Rename the two TEMP TABLEs to the required baseball_ prefix, all references
  (finding 10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): validate invite code is alphanumeric before router.push (CodeRabbit #868)

The hint text promises "letters and numbers" but only length was checked,
letting URI-breaking characters (?, #, /) reach router.push(`/baseball/join/${trimmed}`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): suppressHydrationWarning on legacy-upload created_at cell (CodeRabbit #868)

toLocaleDateString() formats with the server's locale/timezone during SSR
but the browser's on hydration, risking a mismatch warning. Matches this
repo's existing suppressHydrationWarning-on-the-enclosing-element precedent
(LocalTime.tsx, RelativeTime.tsx, Fairway calendar/announcements components).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): hide Stats Center import actions for staff with neither capability (CodeRabbit #868)

canManageImports=false conflated stats-capable staff (routed through the
/stats/upload shim) with staff holding NEITHER can_manage_imports nor
can_manage_stats, whom both routes would just bounce off their own
middleware gate. page.tsx now Promise.all's a second hasBaseballCapability
call for can_manage_stats and passes both down; StatsCenterClient renders
the header "Import Center" action and the empty-state "Import a box score"
CTA only when canManageImports || canManageStats holds, keeping the existing
importEntryHref branch for the visible cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): filter provenance to the reading-bearing rows sampleSize counts (CodeRabbit #868)

avg_exit_velocity/avg_launch_angle (hitting) and avg_velocity (pitching) each
correctly narrow sampleSize to rows with an actual non-null reading, but
still passed the FULL bbProv/pProv array (every batted ball / pitch,
hand-charted or radar-read) into dominantTrust/dominantContext. A majority
of hand-charted, no-reading rows could drag trustTier down to 'unverified'
even when every row that fed the average was 'official' radar data. Pass the
same `.filter(reading != null)` array as provenance in all three call sites.
Extends the #864 sampleSize-honesty suite with mixed-trust regression tests
(few official radar rows + many unverified hand-charted rows -> trustTier
must reflect only the radar rows) for the batting and pitching paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

* fix(baseball): resolve capability requirement inside guarded flow + reject empty results (CodeRabbit #868)

Two related fixes to withBaseballAction:

- The (possibly args-conditional) requiredCapability resolver ran BEFORE
  Sentry.withScope/the wrapper's own try/catch even started, so a throwing
  resolver (e.g. a malformed/omitted argument) threw raw and unsanitized,
  skipping AUTH, Sentry, and logServerException entirely. Resolution now
  happens inside the guarded try/catch, right after AUTH resolves and before
  capability enforcement — a throwing resolver now produces the same
  sanitized BaseballActionError + Sentry-logged path as any other action
  failure. Still resolved exactly once, from the same args reference; tags/
  breadcrumbs are set from the resolved value immediately afterward.
- requiredCapability's array forms are now typed as non-empty tuples
  (readonly [BaseballCapability, ...BaseballCapability[]]) so `[]` is a
  compile-time error, and a resolver that manufactures an empty array at
  runtime anyway is rejected with a thrown BaseballCapabilityError (fail
  closed) instead of falling through to `resolvedCapabilityList[-1]` ===
  undefined being passed to requireBaseballCapability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant