-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathannotation.json
More file actions
2767 lines (2714 loc) · 158 KB
/
Copy pathannotation.json
File metadata and controls
2767 lines (2714 loc) · 158 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
{
"version": 1,
"modules": {
"Pelotero.Prelude": {
"decisions": [
{
"name": "HandMaintainedVersionString",
"what": "appVersion is a hand-edited Text constant rather than read from cabal-generated Paths_pelotero_engine.version",
"why": [
"Reading from Paths_* requires plumbing the autogen module into the import graph and pulls Data.Version handling into every site that logs the version.",
"The risk is drift: bumping the .cabal version without bumping this constant produces a binary that lies about itself in the fetch log.",
"Mitigation is process: a release checklist item, not a code-level guarantee."
],
"affects": ["appVersion"]
}
],
"invariants": {
"appName": [
"compile-time constant; canonical application name used in logging and any place that needs to identify this binary as the producer of a record",
"OverloadedStrings is enabled solely so the string literal types as Text without explicit pack"
],
"appVersion": [
"hand-maintained version string; not derived from cabal's PackageInfo_pelotero_engine, so it can drift from the .cabal file unless updated by hand",
"intended to be embedded wherever the engine identifies itself (audit fields, fetch-log enrichment)"
]
}
},
"Pelotero.Provider.ExternalId": {
"decisions": [
{
"name": "SingleHelperEliminatesImplicitContract",
"what": "All upstream-id <-> external_id Text encodings live in this one module; every site that crosses that boundary calls a typed helper rather than rolling its own T.pack . show",
"why": [
"The legacy pattern (T.pack . show . unTeamId scattered across Sync.Players, Sync.Schedule, Sync.Boxscores) was an implicit string contract; if anyone changed one site without changing the others, every game upsert silently skipped because resolveTeam returned Nothing for every team.",
"Centralising the encoding makes the contract a function call: changing the encoding requires editing one module, and the type system enforces that callers go through the helper.",
"The roundtrip property (externalIdToTeamId . externalIdFromTeamId === Just) is checked by Hedgehog so encoding drift between forward and reverse paths is caught at test time."
],
"affects": [
"externalIdFromTeamId",
"externalIdFromPlayerId",
"externalIdFromGameId",
"externalIdToTeamId",
"externalIdToPlayerId",
"externalIdToGameId"
]
},
{
"name": "PerIdTypePairsNotPolymorphic",
"what": "Six concrete functions (one from + one to per id type) rather than a single class-based polymorphic encoder",
"why": [
"Three id types is below the threshold where typeclass machinery earns its keep; six monomorphic helpers are easier to grep, easier to test, and easier to read at call sites.",
"If a fourth id type appears (e.g. ProviderInternal player ids using a different scheme), this is the place to revisit; until then, monomorphic wins."
],
"affects": []
}
],
"invariants": {
"externalIdFromTeamId": [
"TeamId -> Text via T.pack . show . unTeamId",
"stable byte-for-byte: this is the canonical wire encoding stored in team_external_id.external_id"
],
"externalIdFromPlayerId": [
"PlayerId -> Text via T.pack . show . unPlayerId",
"consumed by Sync.Players.upsertAllPlayers and Sync.Boxscores.upsertEntries (forward) and Sync.Boxscores.syncOne (reverse via the roundtrip)"
],
"externalIdFromGameId": [
"GameId -> Text via T.pack . show . unGameId",
"consumed by Sync.Schedule.upsertOneGame and Sync.Boxscores.syncOne"
],
"externalIdToTeamId": [
"Text -> Maybe TeamId; reads via Text.Read.readMaybe and wraps in TeamId",
"returns Nothing on any non-integer or negative value; an empty Text returns Nothing"
],
"externalIdToPlayerId": [
"mirror of externalIdToTeamId"
],
"externalIdToGameId": [
"mirror of externalIdToTeamId"
]
}
},
"Pelotero.Effects.Logging": {
"decisions": [
{
"name": "KatipPatternLiftedFromCheeblr",
"what": "Logging effect uses the Katip pattern: logFM, logItem, addNamespace, addContext; production interpreter writes structured JSON to stdout",
"why": [
"Cheeblr already runs this pattern in production with structured search-friendly JSON logs; reusing it means we get the same querying, the same tooling, and one less invented wheel.",
"Structured logs make per-warning grepping (e.g. 'all UnknownPosition warnings in the last week') a one-line jq query rather than a regex over freeform text.",
"Stdout-only at the application layer; the runtime (systemd / OCI runtime / k8s) handles persistence and rotation. We deliberately avoid in-process file rotation because it duplicates the runtime's job."
],
"affects": ["logFM", "logItem", "addNamespace", "addContext", "runLoggingKatip", "runLoggingCapture", "runLoggingDiscard"]
},
{
"name": "ReplacesConvertLogWarnings",
"what": "Pelotero.MLB.Convert.logWarnings (direct stderr IO) is gone; warning emission goes through Logging.logFM with structured fields",
"why": [
"Convert.logWarnings wrote bare stderr lines with a 'convert: ' prefix; the prefix was the only structure log scrapers had to grab onto.",
"Going through Logging means each ConvertWarning becomes a structured log item with its constructor name as a tag; downstream systems can filter on the tag, not on string-prefix scraping.",
"The Handle-parameterised logWarningsTo indirection existed only to make logWarnings testable; with a capturing test interpreter (runLoggingCapture), that indirection disappears."
],
"affects": ["logFM"]
}
],
"invariants": {
"logFM": [
"Logging send-only; structured log emission with severity + message",
"production: writes a JSON line to stdout via Katip's stdout scribe",
"test: appends to an IORef-backed [LogLine] under runLoggingCapture, or no-ops under runLoggingDiscard"
],
"logItem": [
"Logging send-only; logs a Katip-LogItem-encodable payload with a severity",
"used for richer structured events (e.g. SyncResult summaries) where a plain text message loses the structure"
],
"addNamespace": [
"Logging operation; runs an inner Eff under a nested Katip namespace",
"used to scope all logs from a syncRosters call under e.g. 'sync.rosters' so traces are easy to filter"
],
"addContext": [
"Logging operation; attaches structured key/value context to all logs emitted by the inner Eff",
"carries through to JSON output as additional fields on each log line"
],
"runLoggingKatip": {
"intent": "Production interpreter; writes JSON-formatted log lines to stdout via Katip's stdout scribe",
"effects": ["IOE"],
"notes": [
"scribes and namespace are wired once at startup",
"stdout-only; the runtime is responsible for persistence/rotation"
]
},
"runLoggingCapture": {
"intent": "Test interpreter that captures every emitted log into an IORef-backed [LogLine] returned alongside the inner action's result",
"effects": ["IOE"],
"notes": [
"uses IOE because it needs an IORef; not pure",
"captures the structured payload (severity, namespace, message), suitable for asserting 'these warnings were emitted in this order' in property tests"
]
},
"runLoggingDiscard": {
"intent": "Test interpreter that silently drops every log; pure (no effects on the inner stack)",
"notes": [
"default for tests that don't care about logging output",
"leaves the rest of the effect stack untouched so callers don't have to thread IOE just to silence logs"
]
}
}
},
"Pelotero.Draft": {
"decisions": [
{
"name": "PureLogicNoEffects",
"what": "Pelotero.Draft is the pure data layer for the draft state machine: the DraftState ADT, the DraftCommand / DraftEvent / DraftError sums, the DraftContext / DraftPickEntry / DraftSummary records, plus a handful of total query helpers (currentPicker, picksRemaining, isPlayerAvailable). No effect dependencies.",
"why": [
"Phase D.1a separated the transition logic from the persistence layer so the state machine is testable with hedgehog without any DB or in-memory effect runner.",
"The transition logic itself lives in Pelotero.Draft.Machine as draftAction (and the existential-erasing wrapper runDraftCommand); the effectful handler in Pelotero.Draft.Run (Phase D.1c) wraps runDraftCommand with PlayerRanking / DraftPick / Players / Logging / Clock; that is where commands actually get persisted.",
"Pelotero.Domain.Draft remains the upstream-id-keyed pre-compute layer (order generation from a strategy + team list); this module is DB-id-keyed because the state machine talks to the persistence layer."
],
"affects": []
},
{
"name": "AutoPickIsNotACommand",
"what": "DraftCommand has only StartDraft, MakePick, EndDraft; auto-picking is a wrapper at the effect layer (D.1c) that consults PlayerRanking, intersects with dcAvailable, and issues MakePick with the chosen player",
"why": [
"Keeps the pure logic with one place-a-player path rather than two; the strategy logic for which player gets picked stays separate from the how of recording it.",
"Means the state machine itself doesn't take a ranking source as input: the ranking lookup happens before MakePick is constructed.",
"If a future strategy variant emerges (round-specific overrides, drop-and-replace), it lives at the effect layer alongside the existing auto-pick wrapper, not as a new state-machine command."
],
"affects": []
}
],
"invariants": {
"initialState": [
"WaitingToStart; the empty start of any draft"
],
"currentPicker": [
"Whose pick is up? Nothing if the draft hasn't started or has finished.",
"Used by Pelotero.Draft.Run's auto-pick wrapper (D.1c) to identify which team's ranking to consult."
],
"picksRemaining": [
"Length of dcRemaining inside Drafting; 0 outside Drafting."
],
"isPlayerAvailable": [
"Set membership in dcAvailable; False outside Drafting."
]
}
},
"Pelotero.Draft.Machine": {
"decisions": [
{
"name": "CremTopologyForCompileTimeLegalTransitions",
"what": "DraftTopology is declared as a crem Topology with explicit allowed transitions per vertex (WaitingToStartV -> {self, DraftingV}; DraftingV -> {self, CompleteV}; CompleteV -> {self}); draftAction is checked at compile time against the topology, so an illegal transition is a type error rather than a runtime branch",
"why": [
"An invariant like 'you cannot go from Complete back to Drafting' wants to be enforced by the compiler, not by a runtime guard that future refactors might quietly remove.",
"The self-edges on every vertex are deliberate: rejection cases (NotYourTurn, PlayerAlreadyDrafted, DraftAlreadyComplete) return the same vertex they entered with, so the topology must permit self-loops.",
"Cost: the GADT DraftStateG plus the singletons machinery (SDraftVertex via singletons-th) is a chunk of type-level apparatus, and DraftAction's return type is verbose. Acceptable because the alternative (a flat DraftState sum + a runtime 'is this legal?' check inside applyCommand) is what we had before and is exactly what we're moving away from."
],
"affects": ["draftAction"]
},
{
"name": "ExistentialWrapperForCallerErgonomics",
"what": "SomeDraftStateG hides the vertex index so callers can hold draft state without threading the singleton type through their signatures; runDraftCommand is the consumer-facing wrapper that takes a SomeDraftStateG and a command and returns the next SomeDraftStateG plus the result",
"why": [
"Pelotero.Draft.Run.applyAndPersist sees state as an opaque value, not as a vertex-indexed thing; the persistence loop doesn't care which vertex it's at except via the runtime tag (draftStateVertex).",
"Hiding the index also keeps the public API expressible without type-level Haskell at every call site; downstream code reads as plain Haskell.",
"fromDraftState / toDraftState exist as the bridge to and from the unindexed Pelotero.Draft.DraftState; useful when the caller has constructed state from raw data (e.g. recovery from DB) or wants to render state as the simpler sum type."
],
"affects": ["runDraftCommand", "toSomeDraftStateG", "fromDraftState", "toDraftState"]
},
{
"name": "LastPickEmitsTwoEvents",
"what": "draftAction on MakePick for the last remaining pick emits both PickRecorded and DraftCompleted in that order; EndDraft from Drafting emits only DraftCompleted",
"why": [
"The handler in Pelotero.Draft.Run persists both events in order: insert the DraftPick row first, then log the completion. The league_config status update is the orchestrator's job, not the state-machine handler's, per Pelotero.Draft.Run's StatusUpdateNotInScope decision.",
"Crash between events leaves the pick recorded with the in-memory machine state still consistent: the next run sees an empty dcRemaining and re-runs draftAction to get DraftCompleted again; idempotent on the in-memory side. DB-level idempotency on partial-draft retries is documented separately under NoCrashRecovery.",
"Flipping the order would leave a 'completed' log line with the last pick missing from draft_pick: a worse partial state for anyone reading logs."
],
"affects": ["draftAction"]
},
{
"name": "PicksMadeListSnocAppend",
"what": "DraftContext.dcPicksMade is appended to in pick order (oldest first) using snoc; total cost is O(n^2) across a full draft",
"why": [
"n is small: at most ~300 picks for a 25-team / 12-round league. 300^2 = 90,000 cons cells; not a perf concern.",
"Oldest-first ordering means the DraftSummary doesn't need a final reverse step, and the audit trail reads naturally in chronological order without calling reverse on every read.",
"If a much larger draft format appears (50-team dynasty with 30 rounds = 1500 picks), revisit; until then the simpler shape wins."
],
"affects": ["draftAction"]
}
],
"invariants": {
"initialMachineState": [
"SomeDraftStateG SWaitingToStartV WaitingToStartG; the existential-wrapped starting state for every draft"
],
"draftStateVertex": [
"extracts the runtime DraftVertex tag from a SomeDraftStateG without unwrapping the GADT payload",
"useful for the auto-draft loop which needs to know 'are we in Drafting?' as a value-level predicate"
],
"fromDraftState": [
"DraftState -> SomeDraftStateG; bridges from the unindexed sum (Pelotero.Draft) into the indexed GADT shape",
"constructs the appropriate singleton tag per constructor"
],
"toDraftState": [
"DraftStateG v -> DraftState; the inverse of fromDraftState modulo vertex index erasure"
],
"toSomeDraftStateG": [
"DraftStateG v -> SomeDraftStateG; trivially wraps an indexed state under its singleton"
],
"draftAction": {
"intent": "Per-vertex transition: given a state indexed by vertex v and a DraftCommand, return an ActionResult under DraftTopology with either an event list or a DraftError plus the next (vertex-indexed) state",
"notes": [
"Pure inside Identity; same inputs -> same outputs.",
"Rejection cases (NotYourTurn, PlayerAlreadyDrafted, DraftAlreadyComplete, DraftNotStarted, EmptyDraftPlan, DraftAlreadyStarted) emit Left and stay on the same vertex via the topology's self-loops.",
"MakePick on the final dcRemaining entry transitions to CompleteV emitting [PickRecorded entry, DraftCompleted summary].",
"EndDraft from Drafting transitions to CompleteV emitting [DraftCompleted summary] with whatever picks have been made so far; useful for early commissioner-driven termination.",
"An empty DraftPlan (dpOrder == []) returns Left EmptyDraftPlan rather than transitioning to a degenerate Complete state."
]
},
"runDraftCommand": [
"SomeDraftStateG -> DraftCommand -> (Either DraftError [DraftEvent], SomeDraftStateG)",
"the existential-erasing wrapper around draftAction; consumed by Pelotero.Draft.Run.applyAndPersist",
"runs the ActionResult under Identity to extract the pure value pair"
]
}
},
"Pelotero.Score": {
"decisions": [
{
"name": "PureKernelSplitFromEffects",
"what": "scorePlayerPure / sumBattingPoints / sumPitchingPoints / rowToBattingStats / rowToPitchingStats are pure and exported separately from the effectful scoreLeague / scoreTeam",
"why": [
"The pure kernel is the part hedgehog tests can hit without spinning up Postgres.",
"Splitting them lets us unit-test 'given these rows, the score is X' without an in-memory effect runner: just a record literal and a function call.",
"The pure functions deliberately take and return DB row types (BattingRow / PitchingRow), not domain types, because the round-trip BattingRow -> BattingStats -> Points loses no information; storing-then-loading and direct-scoring agree by construction."
],
"affects": ["scorePlayerPure", "sumBattingPoints", "sumPitchingPoints", "rowToBattingStats", "rowToPitchingStats"]
},
{
"name": "MissingLeagueReturnsNothing",
"what": "scoreLeague and scoreTeam both return Maybe; Nothing means the league config (or the team) wasn't in the DB",
"why": [
"Distinguishes 'this league doesn't exist' from 'this league has zero teams' (Just LeagueScore { lscTeams = [] }).",
"An empty lineup still produces a TeamScore with zero points: dropping such teams would be a presentation choice the caller should make.",
"Doesn't validate that the team belongs to the league in scoreTeam: caller's responsibility to check."
],
"affects": ["scoreLeague", "scoreTeam"]
},
{
"name": "PitOutsIsCanonicalNoTextRoundTrip",
"what": "rowToPitchingStats passes pitOuts straight through from the DB row; pitInningsPitched no longer exists in PitchingStats",
"why": [
"Phase B.1 collapsed the redundant pitInningsPitched (Maybe Text) + pitOuts (Maybe Int) pair into pitOuts only. The wire-format string is parsed at convert time and discarded; the DB stores outs; readers see outs.",
"Removes the parseInningsPitched . renderInningsPitched round-trip that previously lived in the score path. Saves one parse and one render per pitching row, more importantly removes a class of subtle bug (a future caller setting pitOuts only would have been silently zeroed by the round-trip).",
"scorePitching now reads pitOuts directly without any parse step."
],
"affects": ["rowToPitchingStats"]
},
{
"name": "PeriodFromUtcStartEnd",
"what": "scoreLeague derives lscPeriodStart / lscPeriodEnd by utctDay'ing lcScoringStart and lcScoringEnd from the league config",
"why": [
"league_config stores UTCTime instants because lineup-lock cutoffs are timezone-sensitive (see Pelotero.Domain.League decision ScoringPeriodAsUtcTime), but the DB game.game_date is Day, so the query needs Day bounds.",
"Truncating UTCTime to Day in UTC is correct only because game_date is also stored in UTC at write time. A change to either column's timezone semantics would break this silently.",
"The two utctDay calls happen once per scoreLeague invocation; not a perf concern."
],
"affects": ["scoreLeague", "scoreTeam"]
},
{
"name": "DateRangePlayerMapBuilds",
"what": "buildPlayerMaps issues exactly two BoxscoreEntry effect calls (getBattingForDateRange and getPitchingForDateRange) for the entire scoring period; the SQL joins game_player_batting/pitching against game on game_id and filters by game.game_date in [startDay, endDay]",
"why": [
"Phase C.2 collapsed the per-game query loop. The previous shape did 2 * N transactions for an N-game period (~900 round-trips for a 30-day MLB period); the date-range version is exactly 2.",
"Correctness preserved: the date-range query joins against gameSchema and filters by game_date, which is exactly the predicate Games.getGamesByDateRange uses, so the fetched row set is identical to what the per-game loop produced.",
"The downstream per-game filter in scoreOnePlayerForGame is unchanged and load-bearing: each player's bucket now contains rows from every game in the period, and the filter narrows to just the game being scored. Removing the filter would silently score every player's full-period stats once per game."
],
"affects": ["buildPlayerMaps"]
},
{
"name": "ScoringReadsFromLineupSnapshots",
"what": "scoreOneTeam reads from LineupSnapshot.getSnapshotsForDateRange (grouped in-memory by game_id), NOT from the team's current lineup_slot rows",
"why": [
"Phase B.3 fixed the correctness bug where scoring used the current lineup against historical games. Real fantasy leagues lock lineups daily; mid-period lineup edits used to retroactively change prior days' scores.",
"The scoring loop is now per-game (which it should always have been): for each game in the period, read the snapshot for that team for that game, score that game's stats against that snapshot, sum.",
"Re-running scoring at any point gives the same answer for the same (league, period, team) triple. This is the invariant owners were checking against and losing trust over.",
"Implementation note (post Phase C.2): the snapshot fetch is also a single date-range query (LSnap.getSnapshotsForDateRange) grouped in-memory by game_id via Map.fromListWith, rather than N per-game fetches."
],
"affects": ["scoreOneTeam", "scoreTeam"]
}
],
"invariants": {
"rowToBattingStats": [
"mechanical Int32 -> Int widening on every Maybe field; lossless"
],
"rowToPitchingStats": [
"Int32 -> Int widening on every Maybe field; lossless",
"pitOuts is preserved straight from the row; no Text reconstruction (post Phase B.1)"
],
"sumBattingPoints": [
"sumPoints over (scoreBatting m . rowToBattingStats): a fold; empty list yields zeroPoints"
],
"sumPitchingPoints": [
"mirror of sumBattingPoints for pitching"
],
"scorePlayerPure": [
"produces a PlayerScore with batting + pitching subtotals and their sum",
"pure: same inputs -> same output, no IO or randomness",
"the canonical hedgehog target for scoring property tests"
],
"scoreLeague": {
"intent": "Score every team in a league across the league's configured scoring period using snapshotted lineups",
"effects": ["LeagueConfig", "LeagueTeam", "LineupSnapshot", "BoxscoreEntry"],
"notes": [
"returns Nothing if the league config row is absent",
"empty lineups produce zero-point TeamScores rather than being dropped",
"uses LineupSnapshot.getSnapshotsForDateRange (grouped in-memory by game_id), NOT LineupSlot.getSlotsForTeam: see decision ScoringReadsFromLineupSnapshots",
"deterministic across re-runs: same (league, period) inputs always yield the same scores"
]
},
"scoreTeam": [
"scores a single team using the league's period",
"returns Nothing if either the league config or the team is absent",
"does NOT validate that the team belongs to the league"
],
"buildPlayerMaps": [
"takes (startDay, endDay) and issues exactly two date-range queries (Box.getBattingForDateRange and Box.getPitchingForDateRange)",
"buckets results into Map DbPlayerId [BattingRow] and Map DbPlayerId [PitchingRow] via Map.fromListWith (++)",
"exactly 2 database round trips per scoring invocation, independent of period length (post Phase C.2)"
],
"scoreOneTeam": [
"internal helper; not exported. Per-team aggregation: load all snapshot rows for the period via LSnap.getSnapshotsForDateRange, group them by game_id with Map.fromListWith, then for each (gameId, [playerIds]) pair call scoreOnePlayerForGame and aggregate per-player scores across games via Map.fromListWith mergePlayerScores",
"signature subject to change"
],
"scoreOnePlayerForGame": [
"internal; given a player and a game id, filter the player's period-wide row buckets to only that game's rows, then call scorePlayerPure",
"a player on the snapshot with no boxscore activity for that game gets zero points (filter yields empty lists)"
],
"mergePlayerScores": [
"internal; pointwise addition of two PlayerScores, used when aggregating per-game scores for the same player across multiple games"
]
}
},
"Pelotero.Draft.Run": {
"decisions": [
{
"name": "PerCommandPersistencePrimitive",
"what": "applyAndPersist is the per-command primitive: takes one command, runs it through runDraftCommand, persists whichever events come out, returns the new state and the result. Both runAutoDraft and any future manual-pick UI handler go through it.",
"why": [
"Keeps the persistence logic in one place; manual UI picks and auto-picks share the same code path.",
"Returns SomeDraftStateG so the caller threads state across calls explicitly — no IORef, no MVar, no state-holding effect. Makes the primitive fully testable with the existing in-memory effect interpreters.",
"DraftError flows through the Either; AutoDraftError is a strict superset for the loop-specific failures (no candidate, stuck, etc.)."
],
"affects": ["applyAndPersist", "runAutoDraft"]
},
{
"name": "StatusUpdateNotInScope",
"what": "applyAndPersist requires only DraftPick + Clock + Logging; the league_config status transition from 'draft' to 'active' is NOT done here",
"why": [
"Pelotero.Draft.Run's job is recording picks. League lifecycle is a separate concern that belongs to whatever orchestrator started the draft.",
"Decoupling means a replay-pick CLI command can re-run picks without bumping league status, and league-status transitions are tested independently of draft picking.",
"Reverses the implication in D.1a's LastPickEmitsTwoEvents decision that DraftCompleted becomes a league_config status update; that annotation is being narrowed accordingly."
],
"affects": ["applyAndPersist", "persistEvent", "runAutoDraft"]
},
{
"name": "AutoPickFallbackToLowestIdAvailable",
"what": "autoPickCommand falls back to Set.lookupMin (dcAvailable ctx) when the team's ranking has no available candidates",
"why": [
"Deterministic — same draft plan + same DB state always picks the same player, so the loop is reproducible across runs.",
"Position-aware fallback (e.g. fill the team's positional needs) is more sophisticated and is properly D.1d's concern when porting AutoDraft.hs from old_src/.",
"WarningS log line on every fallback so operators can see when ranking coverage is poor — the metric to watch is fallback-rate-per-draft."
],
"affects": ["autoPickCommand"]
},
{
"name": "NoCrashRecovery",
"what": "If the auto-draft loop crashes mid-draft, the partially-inserted DraftPick rows must be manually cleaned up before retry; recordPickT's Abort-on-conflict means a naive retry will fail at the first already-recorded pick",
"why": [
"State-from-DB recovery (read existing picks, reconstruct DraftContext, resume from where we left off) is genuinely useful but well beyond D.1c's scope.",
"The Abort-on-conflict fail-fast is the correct default — silent retry of duplicate picks would mask real bugs (double-pick, race condition, stale state, draft-id reuse).",
"Operators retrying a partial draft DELETE FROM draft_pick WHERE league_config_id = X and re-run runAutoDraft. Not elegant, but the failure is rare enough that the explicit cleanup is acceptable for now."
],
"affects": ["runAutoDraft"]
}
],
"invariants": {
"applyAndPersist": [
"Takes (league, state, command); runs command through runDraftCommand; persists events for Right; logs at WarningS and returns Left for rejection.",
"Rejection cases leave state unchanged (relies on the topology's self-loops in Pelotero.Draft.Machine).",
"PickRecorded events become recordPick calls with picked_at = Clock.now; DraftStarted and DraftCompleted are log-only."
],
"persistEvent": [
"PickRecorded -> recordPick with server-stamped picked_at and the league_config id from the caller.",
"DraftStarted -> InfoS log line with league id and team count.",
"DraftCompleted -> InfoS log line with league id and pick count."
],
"autoPickCommand": [
"Reads PR.getRankingsForTeam for the team whose turn it is.",
"Filters rankings to dcAvailable; picks the head if any remain.",
"Falls back to Set.lookupMin (dcAvailable ctx) if the ranked-and-available list is empty; logs WarningS on fallback.",
"Returns Left (AutoDraftNoCandidate team) only if dcAvailable is empty AND rankings are exhausted."
],
"runAutoDraft": {
"intent": "Drive a draft from initialMachineState to Complete via auto-picks; return the DraftSummary or an AutoDraftError.",
"notes": [
"Persists every pick as a DraftPickRow as it happens.",
"Single-process invocation; state lives only in this Eff stack — no IORef, no DB-state recovery on restart.",
"DraftPlan must be pre-built by the caller (Pelotero.Domain.Draft.generateDraftOrder + active player pool); this function does not construct the plan.",
"Future CLI command 'pelotero draft run --league=X' will live above this and own the plan construction."
]
}
}
},
"Pelotero.Lineup.Snapshot": {
"decisions": [
{
"name": "SnapshotPerGamePerTeamWithExistsCheck",
"what": "snapshotLineupsForGame copies the current lineup_slot rows into lineup_snapshot keyed by (league_team_id, game_id); idempotency is enforced both by an explicit 'snapshot exists?' check at the orchestration level and by a UNIQUE constraint at the table level",
"why": [
"The (league_team_id, game_id) key gives us 'what was this team's lineup for this specific game'. Re-running scoring against a snapshot gives a deterministic answer.",
"Once a snapshot exists for a (team, game) pair, it is treated as locked-in and is NOT overwritten by re-running. ON CONFLICT DO NOTHING in writeSnapshotsT plus the explicit pre-check together ensure 'first snapshot wins'. This deliberately prevents late-edit scenarios where an owner changes a lineup after the snapshot job ran.",
"There is no game-start-time guard at the orchestration level; the schema only tracks game_date (Day), not a precise instant. Operators are responsible for scheduling snapshots before first pitch."
],
"affects": ["snapshotLineupsForTeam", "snapshotLineupsForGame"]
},
{
"name": "TypedResultNotUnit",
"what": "snapshotLineupsForTeam returns PerTeamResult and snapshotLineupsForGame returns SnapshotResult; both are aggregates that surface counts (teams snapshotted vs already-done, rows inserted)",
"why": [
"An earlier sketch had snapshotLineupsForGame return (); operators couldn't tell whether anything happened.",
"PerTeamResult has two constructors: TeamSnapshotted Int (n rows submitted to the snapshot table for this team — the number reflects rows attempted; under concurrent snapshot jobs ON CONFLICT DO NOTHING could silently drop duplicates so n may overstate actual inserts in that case) and TeamAlreadyHasSnapshot (snapshotExistsForTeamGame returned True; nothing was written).",
"SnapshotResult is a record { snapTeamsSnapshotted, snapTeamsAlreadyDone, snapRowsInserted } with a Monoid instance: mempty is all zeros and (<>) is pointwise addition, so the per-league aggregation is just mconcat."
],
"affects": ["snapshotLineupsForTeam", "snapshotLineupsForGame"]
},
{
"name": "ModuleLivesInLineupNotEffects",
"what": "snapshot orchestration lives at Pelotero.Lineup.Snapshot, not under Pelotero.Effects",
"why": [
"This module composes multiple effects (LineupSlot, LineupSnapshot, LeagueConfig, LeagueTeam, Logging) to do a piece of business logic; it is not itself an effect definition.",
"Pelotero.Effects.LineupSnapshot is the dispatch interface; Pelotero.Lineup.Snapshot is the orchestration layer that uses it.",
"Mirrors the layering between Pelotero.Sync.* (orchestration) and Pelotero.Effects.* (dispatch)."
],
"affects": []
},
{
"name": "EmptyLineupCornerCase",
"what": "If a team has no lineup_slot rows at snapshot time, snapshotLineupsForTeam writes zero rows and returns TeamSnapshotted 0; the snapshotExistsForTeamGame query will subsequently return False (no rows), so a re-run with a non-empty lineup will write a fresh snapshot",
"why": [
"This is a deliberate quirk: empty-lineup snapshots and 'no snapshot taken' are indistinguishable at the storage level.",
"In practice this is desirable — a team that joins late and has no lineup at the first snapshot should still get scored once they set one — but it does mean 'TeamSnapshotted 0' is operationally meaningful."
],
"affects": ["snapshotLineupsForTeam"]
}
],
"invariants": {
"snapshotLineupsForTeam": {
"intent": "Snapshot one team's current lineup for one game; idempotent on (team, game)",
"effects": ["LineupSlot", "LineupSnapshot", "Logging"],
"notes": [
"first calls LSnap.snapshotExistsForTeamGame; if True, returns TeamAlreadyHasSnapshot without touching lineup_slot",
"if False, reads the team's lineup via LS.getSlotsForTeam, projects each slot row to a LineupSnapshotRow tagged with the game id, and writes via LSnap.writeSnapshots",
"logs an InfoS event with team/game/row count on the success path"
]
},
"snapshotLineupsForGame": {
"intent": "Snapshot every team in every active league for one game",
"effects": ["LeagueConfig", "LeagueTeam", "LineupSlot", "LineupSnapshot", "Logging"],
"notes": [
"loads all LeagueConfig rows via LC.getAll, filters to those with lcStatus == \"active\"",
"for each active league, fetches league teams via LT.getForLeague and snapshots each via snapshotLineupsForTeam",
"aggregates per-team results into a SnapshotResult and logs an InfoS summary"
]
},
"toSnapshot": [
"internal helper; projects (DbGameId, LineupSlotRow) into a LineupSnapshotRow"
]
}
},
"Pelotero.Sync.Players": {
"decisions": [
{
"name": "TeamsBeforePlayersOrdering",
"what": "syncRosters upserts all teams first, builds a TeamId -> DbTeamId map, then upserts players using that map to resolve playerRowCurrentTeamId",
"why": [
"Players carry a foreign key to team via current_team_id; if the team doesn't exist in the DB at insert time, the FK either fails (with constraint enforcement) or installs a dangling reference (without).",
"Building the map in memory between the two phases avoids a second round-trip per player to look up the DB team id.",
"The two phases share one syncedAt timestamp from the Clock effect, so last_synced_at on team and player rows match exactly for a given sync run, useful when reasoning about staleness."
],
"affects": ["syncRosters", "upsertAllTeams", "upsertAllPlayers"]
},
{
"name": "ProviderAgnosticEntryPoint",
"what": "syncRosters takes already-converted [Team] and [Player] domain values plus a precomputed SHA-256, never touches wire types or HTTP",
"why": [
"Keeps the sync pipeline testable in isolation: tests construct domain values directly and run against in-memory effect interpreters.",
"Allows alternative providers (a future non-MLB feed, manual data import) to reuse this exact code path by doing their own wire->domain conversion upstream.",
"The SHA is computed by the caller because only the caller has the raw bytes; passing it in here keeps this module from depending on Crypto.Hash."
],
"affects": ["syncRosters"]
},
{
"name": "ResourceLiteralActiveRosters",
"what": "syncRosters writes fetchLogResource = 'active-rosters' as a literal rather than parameterising it",
"why": [
"The MLB roster endpoint queried (rosterUrl) filters to activeStatus=ACTIVE on the server side, so this resource name is the literal truth of what was fetched.",
"If a future scope adds inactive/40-man/minor-league fetches, that becomes a new resource name passed in by the caller, not a flag here."
],
"affects": ["syncRosters"]
},
{
"name": "ExternalIdViaCentralisedHelper",
"what": "upsertAllTeams and upsertAllPlayers call Pelotero.Provider.ExternalId.externalIdFromTeamId / externalIdFromPlayerId, never inline T.pack . show",
"why": [
"Phase A.1 collapsed the implicit cross-module string contract into a typed helper. The sync pipeline now goes through one canonical encoding for all upstream id -> Text conversions.",
"The Hedgehog roundtrip property (externalIdToTeamId . externalIdFromTeamId === Just) catches any future encoding drift at test time.",
"Sync.Schedule.resolveTeam uses the matching reverse function (externalIdToTeamId via the lookup); both sides go through the same module."
],
"affects": ["upsertAllTeams", "upsertAllPlayers"]
},
{
"name": "TotalHandCharFromDomain",
"what": "upsertAllPlayers uses Pelotero.Domain.Player.handChar (total) instead of T.head . renderHandedness (partial)",
"why": [
"Phase A.6 introduced handChar :: Handedness -> Char as an exhaustive case-of in the domain layer. T.head's partiality is gone because the conversion is no longer a Text indexing operation.",
"The total function lives next to the Handedness type so any new constructor forces an exhaustivity check at compile time, eliminating the legacy class of bug where a renderHandedness change could silently break this caller.",
"DB column for bat_side / pitch_hand is still Text (CHAR(1) has padding gotchas in PostgreSQL), but the API path is now total end-to-end."
],
"affects": ["upsertAllPlayers"]
},
{
"name": "IdempotencyByPayloadSha",
"what": "syncRosters short-circuits when the inbound payloadSha matches the most recent fetch-log row for ('provider', 'active-rosters', 'scope'); the no-op path returns SyncResult { syncTeamsUpserted = 0, syncPlayersUpserted = 0, syncFetchSha256 = payloadSha } and emits one InfoS log line",
"why": [
"Re-running with an unchanged payload should not re-upsert ~30 teams + ~700 players or write a new fetch-log row; that work is wasted writes and DB contention. The fetch-log already records payload_sha256 for exactly this purpose; the short-circuit reads at most one row before deciding whether to do anything.",
"Ordering is load-bearing: the fetch-log INSERT is the LAST step in the non-skip path, AFTER both upserts complete. A crash mid-upsert leaves no log row, so the next run retries rather than silently skips a partial state. Inverting this ordering would mask partial writes — comments at the call site flag this for future maintainers.",
"Phase C.1."
],
"affects": ["syncRosters"]
}
],
"invariants": {
"syncRosters": {
"intent": "Provider-agnostic upsert pipeline: SHA-checked short-circuit, then (on miss) teams, players, and finally a fetch-log entry",
"effects": ["Players", "Teams", "FetchLog", "Clock", "Logging"],
"notes": [
"single Clock.now call shared by both team and player upserts; both rows get identical last_synced_at",
"fetchLogResource hardcoded to 'active-rosters'; scope is supplied by the caller (typically the season as Text)",
"fetchLogRecordCount is the player count, NOT the team count; teams are bookkeeping",
"fetchLogId and fetchLogFetchedAt are Nothing on the inbound row; the DB layer fills them via DEFAULT",
"SHA short-circuit (Phase C.1): if payloadSha matches the most recent fetchLogRow.fetchLogPayloadSha256 for this (provider, 'active-rosters', scope), returns a zero-row SyncResult and emits exactly one InfoS log line. Both counts being zero is the canonical 'we skipped' signal — distinguishable from 'first call with empty inputs' by the syncFetchSha256 echoing the input",
"the fetch-log INSERT is the last step in the non-skip path; reordering it before the upserts would let a crash at the wrong moment silently mask a partial-state on the next run"
],
"consumes": [
"CLI sync command (process startup or scheduled run)"
]
},
"upsertAllTeams": [
"produces Map TeamId DbTeamId where the key is the upstream MLB id and the value is the surrogate DB id, in the order traverse visits the input list",
"extId = externalIdFromTeamId team — single source of truth for the encoding",
"teamRowId is Nothing on insert; the DB layer assigns the surrogate id"
],
"upsertAllPlayers": [
"uses the team map from upsertAllTeams to resolve playerRowCurrentTeamId; players whose upstream team isn't in the map (unknown / minor league call-up) get Nothing for current_team_id",
"extId = externalIdFromPlayerId player — single source of truth",
"discards the DbPlayerId returned by upsertPlayerByExternalId; the caller doesn't need it",
"handedness conversion via handChar (total Domain function), not T.head of renderHandedness"
]
}
},
"Pelotero.Sync.Schedule": {
"decisions": [
{
"name": "SkipGamesWithUnknownTeams",
"what": "syncSchedule resolves both away and home teams via lookupTeamByExternalId; if either is missing, the entire game is skipped and counted in schedGamesSkipped",
"why": [
"The schedule endpoint can include teams that haven't been ingested yet (spring training affiliates, all-star game rosters, exhibition opponents).",
"Inserting a game with a NULL or fabricated team_id violates the foreign-key contract and silently corrupts later joins.",
"Skipping is recoverable: re-run after a roster sync and the game shows up. Failing the whole batch on one missing reference is not.",
"The skip count is surfaced in ScheduleSyncResult so operators can detect when this is happening at scale."
],
"affects": ["syncSchedule", "upsertOneGame"]
},
{
"name": "FetchLogResourceLiteralSchedule",
"what": "syncSchedule writes fetchLogResource = 'schedule' as a literal rather than parameterising it",
"why": [
"Mirrors the convention in Pelotero.Sync.Players where 'active-rosters' is the literal resource name.",
"If a future scope adds e.g. minor-league schedule fetches, that becomes a new resource string, not a flag here."
],
"affects": ["syncSchedule"]
},
{
"name": "ExternalIdViaCentralisedHelper",
"what": "upsertOneGame and resolveTeam call Pelotero.Provider.ExternalId helpers; no inline encoding",
"why": [
"Phase A.1 made resolveTeam use externalIdFromTeamId on the lookup side and upsertOneGame use externalIdFromGameId for game external ids.",
"The implicit cross-module contract that previously lived in source comments ('the show-of-Int convention here MUST match upsertAllTeams') is now a typed function call. Source comments don't need to assert what the type system enforces."
],
"affects": ["upsertOneGame", "resolveTeam"]
},
{
"name": "IdempotencyByPayloadSha",
"what": "syncSchedule short-circuits when the inbound payloadSha matches the most recent fetch-log row for ('provider', 'schedule', 'scope'); the no-op path returns ScheduleSyncResult { schedGamesUpserted = 0, schedGamesSkipped = 0, schedFetchSha256 = payloadSha } and emits one InfoS log line",
"why": [
"Symmetric to the syncRosters short-circuit (Phase C.1). Re-running with an unchanged schedule payload should not re-upsert hundreds of games.",
"Ordering matters here too: the fetch-log INSERT is the LAST step in the non-skip path. A crash mid-upsert leaves no log row, so retry is safe.",
"Both counts (schedGamesUpserted and schedGamesSkipped) being zero is the unambiguous 'we skipped entirely' signal; distinguishable from a non-skip run with an empty input list because the input list of zero games is itself a degenerate non-skip case that still writes a fetch-log row.",
"Phase C.1."
],
"affects": ["syncSchedule"]
}
],
"invariants": {
"syncSchedule": {
"intent": "Provider-agnostic upsert pipeline for already-converted [Game] domain values: SHA-checked short-circuit, then (on miss) per-game upserts and a fetch-log entry",
"effects": ["Games", "Teams", "FetchLog", "Clock", "Logging"],
"notes": [
"writes one fetch-log row per call (in the non-skip path) with resource = 'schedule', scope supplied by caller (typically a date-range string like '2025-04-01..2025-04-07')",
"fetchLogRecordCount is the total games count, NOT just upserted games; matches the input list length so an operator can compare against schedGamesUpserted to detect skip-rate",
"single Clock.now call shared across all game upserts in this run",
"iteration order is the input list order",
"SHA short-circuit (Phase C.1): if payloadSha matches the most recent fetchLogRow.fetchLogPayloadSha256 for this (provider, 'schedule', scope), returns a zero-row ScheduleSyncResult and emits exactly one InfoS log line. Both schedGamesUpserted and schedGamesSkipped are zero",
"the fetch-log INSERT is the last step in the non-skip path, mirroring syncRosters; this is load-bearing for partial-failure recovery"
]
},
"upsertOneGame": [
"returns True on successful upsert, False on skip (either team unresolved)",
"extId = externalIdFromGameId game (post Phase A.1)",
"discards the DbGameId returned by upsertGameByExternalId; downstream boxscore upserts re-resolve via lookupGameByExternalId at scoring time"
],
"resolveTeam": [
"wraps lookupTeamByExternalId, calling externalIdFromTeamId for the encoding",
"the encoding is now type-checked at compile time via the Provider.ExternalId helper, not asserted via source comments"
]
}
},
"Pelotero.Sync.Boxscores": {
"decisions": [
{
"name": "PerGameFetchLogEntry",
"what": "syncBoxscores writes one FetchLogRow per game (resource = 'boxscore', scope = the GameId as text), not one row per batch",
"why": [
"Catch-up runs ('which boxscores are new since last sync?') become a single getLastFetch query per gameId, which is the natural primitive for incremental work.",
"A per-batch row would force the caller to remember which games it included, defeating the purpose of an external log.",
"Cost: high write volume on a fresh-season backfill (~2400 rows for a full season). Acceptable; provider_fetch_log is append-only and not a query hot path.",
"Phase C.1 reads from this same per-game row to make the SHA short-circuit possible."
],
"affects": ["syncBoxscores", "syncOne"]
},
{
"name": "MissingPlayerSilentSkip",
"what": "If lookupPlayerByExternalId returns Nothing for a boxscore entry's playerId, the entry is silently skipped (no upsert, no warning, no error)",
"why": [
"Real cause: roster sync hasn't run for that player yet (mid-season callup, traded mid-game). Re-running after the next roster sync recovers the data.",
"Failing the whole game would lose 25 known-good batting lines because of one unknown player.",
"Trade-off: silently skipping means no operator visibility. A future improvement would be to count skipped entries in BoxscoreSyncResult so the operator can detect when this is happening at scale."
],
"affects": ["upsertEntries"]
},
{
"name": "MissingTeamSilentDegradation",
"what": "If a boxscore entry has a TeamId that isn't in the local DB, the team_id field is set to NULL rather than skipping the entry",
"why": [
"The batting/pitching row is still useful without team attribution (player's stats are intact); team is a join convenience.",
"MLB occasionally ships parentTeamId values for teams that haven't been ingested (international roster expansions, mid-season ownership swaps).",
"Symmetrical to player handling above but lossier — at least the player would re-sync; an unknown team_id never gets backfilled. Worth flagging if it becomes common."
],
"affects": ["upsertEntries"]
},
{
"name": "InningsPitchedOutsAtConvertTime",
"what": "Conversion to outs happens in Pelotero.MLB.Convert.convertPitching at the wire boundary; pitchingRowFor in this module just copies pitOuts straight through",
"why": [
"Phase B.1 moved the parse to convertPitching, where the wire string and the wire pitOuts are reconciled and a WireFieldDiscrepancy warning is emitted on disagreement. The sync layer no longer carries the redundancy; it sees a single Maybe Int.",
"Previously inningsPitchedOuts here did the prefer-IP-string-then-fall-back-to-outs dance. That logic is gone; this module is now a mechanical Int -> Int32 narrowing per field."
],
"affects": ["pitchingRowFor"]
},
{
"name": "ExternalIdViaCentralisedHelper",
"what": "syncOne uses externalIdFromGameId to resolve GameId -> DbGameId and externalIdFromPlayerId / externalIdFromTeamId for entry resolution",
"why": [
"Phase A.1 unified the encoding; this module is one of the consumer sites. Same compile-time enforcement applies.",
"Phase C.1 also uses externalIdFromGameId as the fetch-log scope, so the SHA-lookup path goes through the same helper as the upsert path."
],
"affects": ["syncOne", "upsertEntries"]
},
{
"name": "BoxscoreOutcomeTypedSum",
"what": "syncOne returns Either BoxscoreSyncError BoxscoreOutcome where BoxscoreOutcome = BoxUpserted Int Int [ConvertWarning] | BoxUnchanged",
"why": [
"Distinguishes 'fetched, parsed, upserted zero rows because the boxscore was genuinely empty' from 'short-circuited on SHA match because nothing changed since last run'. The two are operationally different and need different counters.",
"Replaces the prior (Int, Int, [ConvertWarning]) tuple. The aggregator in syncBoxscores increments boxGamesProcessed for BoxUpserted and boxGamesUnchanged for BoxUnchanged.",
"BoxUnchanged carries no warnings because no parse/convert ran; warnings are exclusively a parse/convert artefact."
],
"affects": ["syncOne", "syncBoxscores"]
},
{
"name": "IdempotencyByPayloadSha",
"what": "syncOne computes sha256Hex of the fetched bytes, looks up the most recent fetch-log row for ('provider', 'boxscore', externalIdFromGameId gid), and returns BoxUnchanged if the SHA matches; in that case the parse, convert, upsert, and fetch-log INSERT are all skipped, and one InfoS log line is emitted",
"why": [
"Asymmetry vs. syncRosters/syncSchedule: those callers compute the SHA before invoking the sync, so the short-circuit precedes the HTTP work entirely. For boxscores, the bytes are fetched inside syncOne via MLBClient, so the HTTP cost is unavoidable.",
"What the short-circuit saves is parse + convert + upsert + fetch-log INSERT — the bulk of the per-game DB work. For a fresh-season backfill where most games are already final (byte-identical on every refetch), this is one HTTP per game and zero DB work.",
"Live games (mid-game boxscores still updating) will never short-circuit because their bytes change on every poll. That is correct behaviour: live data has to be re-ingested.",
"Ordering remains load-bearing: the fetch-log INSERT is the LAST step of the non-skip path, so a crash mid-upsert leaves no log row and the next run retries.",
"Phase C.1."
],
"affects": ["syncOne", "syncBoxscores"]
}
],
"invariants": {
"syncBoxscores": {
"intent": "Process a list of upstream GameIds: resolve each to DbGameId, fetch raw boxscore bytes, decide via SHA whether the payload changed, then (on miss) decode, convert, and upsert per-player batting and pitching rows",
"effects": ["BoxscoreEntry", "Games", "Players", "Teams", "MLBClient", "FetchLog", "Logging"],
"notes": [
"iterates input order; no concurrency at this layer",
"accumulates per-game results into a BoxscoreSyncResult so partial failures don't abort the batch",
"boxConvertWarnings is concatenated across all successfully-processed games (now includes any WireFieldDiscrepancy warnings from convertPitching); SHA-skipped games contribute no warnings",
"boxGamesSeen == boxGamesProcessed + boxGamesUnchanged + length boxErrors as an identity (modulo any future per-game skip categories tracked by boxGamesSkipped)"
]
},
"syncOne": [
"GameNotKnown: lookupGameByExternalId returned Nothing; caller must run schedule sync first",
"FetchFailed: HTTP or transport-level failure from MLBClient",
"ParseFailed: Aeson decode failure on the raw bytes",
"writes FetchLog after a successful upsert pass, NOT before — a partial-write scenario doesn't get logged as a successful fetch",
"C.1 short-circuit: after a successful fetch but before parse, computes sha256Hex of the bytes and reads getLastFetch for ('provider', 'boxscore', externalIdFromGameId gid). On SHA match returns BoxUnchanged; otherwise proceeds with the original parse / convert / upsert / record-fetch path"
],
"upsertEntries": [
"fold over [BoxscoreEntry] producing (battingCount, pitchingCount)",
"missing player -> skip entry (counts unchanged); missing team -> degrade team_id to NULL",
"boxBatting Nothing -> no batting row written; boxPitching Nothing -> no pitching row written"
],
"battingRowFor": ["mechanical Maybe Int -> Maybe Int32 widening for every stat field"],
"pitchingRowFor": ["same as battingRowFor; pitOuts is passed straight through (no IP string reconciliation here post Phase B.1)"],
"i32": ["fmap fromIntegral; Int -> Int32 narrowing"],
"sha256Hex": ["raw ByteString -> hex-encoded Text via SHA-256"]
}
},
"Pelotero.MLB.Urls": {
"decisions": [
{
"name": "UrlsIsolatedAsSwapPoint",
"what": "All MLB Stats API URLs live in this single module with no other logic; the only way to talk to the upstream service is via this module's exports",
"why": [
"If MLB changes the API base or moves an endpoint, this is the only file that needs editing.",
"If a future provider replaces MLB entirely, this module is the natural delete-and-rewrite boundary.",
"Side effect: discourages ad-hoc URL construction scattered through fetch code."
],
"affects": ["rosterUrl", "teamsUrl", "scheduleUrl", "scheduleDateUrl", "boxscoreUrl", "gameStatusUrl"]
}
],
"invariants": {
"rosterUrl": [
"pre-filters to activeStatus=ACTIVE on the server side; inactive players are not returned",
"uses /sports/1/players (sport id 1 = MLB), not /people, because /people requires explicit ID lists"
],
"teamsUrl": [
"uses sportId=1 (MLB) only; minor-league teams are not returned"
],
"scheduleUrl": [
"language=en is hardcoded; the MLB API supports other locales but the engine doesn't model them",
"startDate and endDate are passed as raw String; caller is responsible for the YYYY-MM-DD format MLB expects"
],
"scheduleDateUrl": [
"convenience wrapper for single-day queries; the MLB API does not have a separate per-day endpoint, so we issue a range query with start==end"
],
"boxscoreUrl": [
"v1 endpoint; returns the boxscore object whose shape is parsed by Pelotero.MLB.Wire.Boxscore"
],
"gameStatusUrl": [
"v1.1 endpoint (note the version difference from boxscoreUrl); /feed/live carries scheduled-vs-final status that the boxscore endpoint does not surface cleanly",
"currently UNUSED in the sync pipeline; reserved for the future 'is this game final yet?' check before scoring"
]
}
},
"Pelotero.MLB.Convert": {
"decisions": [
{
"name": "WarningsBesideValuesNotInsteadOf",
"what": "Conversion functions return ([ConvertWarning], Maybe a) or ([ConvertWarning], a) so warnings accumulate without halting the batch",
"why": [
"MLB ships partial records during spring training; halting on the first one means losing the other 700 valid players.",
"Treating malformed records as exceptions would conflate parse errors (caught upstream by Aeson) with data-quality warnings (this layer's job).",
"The caller decides whether to log warnings, ignore them, or fail loudly via Logging."
],
"affects": [
"convertPlayer", "convertPlayers",
"convertSchedule", "convertDateEntry", "convertGame",
"convertPosition", "convertHand",
"convertPitching"
]
},
{
"name": "MLBLooseDateFormat",
"what": "parseDate uses '%Y-%-m-%-d' instead of '%Y-%m-%d'",
"why": [
"MLB's schedule endpoint occasionally returns dates without zero-padding ('2024-3-5' instead of '2024-03-05').",
"The %-m / %-d directives accept both padded and unpadded forms.",
"Strict %m/%d would silently drop ~5% of pre-season games."
],
"affects": ["parseDate"]
},
{
"name": "InvalidPlayerIdDropsRecord",
"what": "convertPlayer drops players with id <= 0 entirely (returns Nothing) and emits an InvalidPlayerId warning",
"why": [
"An id of 0 or negative is structurally meaningless; the surrogate-key lookup would fail and the rest of the row would be junk anyway.",
"Position and handedness warnings, in contrast, downgrade to Nothing for that field while keeping the rest of the player; the player is still useful as a name + team reference.",
"An invalid id removes the only stable identity the record has, so there is nothing left worth keeping."
],
"affects": ["convertPlayer"]
},
{
"name": "BoxscoreEmitsDiscrepancyWarnings",
"what": "convertBoxscore (specifically convertPitching) can now emit WireFieldDiscrepancy warnings when wire pitOuts disagrees with the parsed wire IP string",
"why": [
"Phase B.1 moved IP-string reconciliation here. We parse both wbpInningsPitched and wbpOuts, compare them, and emit WireFieldDiscrepancy if they disagree (rare; usually only during live-update windows).",
"Output remains a single Maybe Int via parseInningsPitched ip <|> wbpOuts: parsed string is preferred (canonical), wire outs is a fallback, both Nothing is preserved.",
"Boxscore conversion previously never emitted warnings (the BoxscoreNeverWarns decision); that decision is now narrowed to the batting path. Pitching can warn; batting still doesn't."
],
"affects": ["convertBoxscore", "convertPitching"]
},
{
"name": "DateParseFailureSilentlyDropsEntireDate",
"what": "convertDateEntry returns ([], []) on date parse failure without emitting an InvalidGameDate warning",
"why": [
"InvalidGameDate is a defined ConvertWarning constructor but no current code path produces one — it exists for the rendering pass and as an extension point.",
"Real MLB dates parse via the loose-padding format; failure here would indicate a structural feed change, not a data-quality issue worth surfacing per game.",
"Known gap: a date corruption in one entry silently loses an entire day's games. Documented as a known limitation."
],
"affects": ["convertDateEntry", "renderWarning"]
}
],
"invariants": {
"renderWarning": [
"renders each ConvertWarning constructor as a single line of stable text",
"format is conventional: 'convert: <subject> <id> <verb> <detail>'; downstream log scrapers can rely on the leading 'convert: ' prefix",
"tshow uses Show on the int id, so player ids appear without quotes",
"WireFieldDiscrepancy renders as 'convert: pitching <playerId> wire field discrepancy: ip=<text> outs=<int>'"
],
"convertPlayer": [
"returns ([InvalidPlayerId], Nothing) for ids <= 0; the empty-warning case never produces Nothing here",
"useName / useLastName / nameSlug all default to T.empty when absent (orEmpty); the DB schema requires NOT NULL, so empty is the safe default",
"playerActive is non-optional in the wire shape; if MLB ever drops it, parsing breaks at Aeson level, not here",
"currentTeam is decoded as Maybe; teamless players (free agents, just-released) get Nothing for playerTeamId"
],
"convertPlayers": [
"concats per-player warnings; the resulting list is in input order (well-defined because the envelope's player list is a list)",
"uses mapMaybe to filter out the Nothing cases (invalid ids); does NOT preserve a placeholder for them"
],
"convertPosition": {
"notes": [
"tries abbreviation first, then code; if both are present and one parses while the other is unknown, prefers the parsed one without warning",
"if NEITHER parses but at least one is present, emits UnknownPosition with the abbreviation if available else the code",
"if both are Nothing, returns ([], Nothing) silently — the position field is genuinely optional for some records"
]
},
"convertHand": [
"WireHandRef wraps a Maybe Text; this function handles WireHandRef Nothing (silent), WireHandRef (Just code) parseable (silent), and WireHandRef (Just code) unknown (warning)",
"no validation that batSide and pitchHand are consistent for switch-hitters; they are independent fields"
],
"convertSchedule": [
"folds over wseDates building (warnings, games) in date order",
"produces GameSchedule wrapping a flat [Game]; the per-date grouping in the wire format is discarded",
"off-days and missing date entries simply produce no games and no warnings"
],
"convertDateEntry": [
"if the date string fails parseDate the WHOLE date entry is dropped silently — see decision DateParseFailureSilentlyDropsEntireDate",
"wdeGames Nothing means 'no games scheduled'; treated as []",
"delegates per-game conversion to convertGame which then attaches its own warnings"
],
"convertGame": [
"extracts away/home team ids from the wire's nested teams.away.team.id and teams.home.team.id",
"if either is missing, emits MissingTeamRef with the gamePk and returns Nothing for the game",
"preserves the gameDate by carrying the parsed Day in from convertDateEntry"
],
"parseDate": [
"returns Nothing for any unparseable input; never throws",
"uses %-m/%-d for MLB's loose padding"
],
"convertBoxscore": [
"returns ([WireFieldDiscrepancy], entries); the only warnings produced here come from convertPitching",
"concatenates away-side then home-side entries; team identification on each entry uses wbpParentTeamId from the wire",
"the GameId comes from the caller"
],
"boxsideEntries": [
"iterates Map.elems of the players map; the wire keys ('ID660271') are discarded",
"missing batting or pitching stats default to emptyBatting / emptyPitching"
],
"convertBatting": [
"mechanical field-by-field copy from WireBoxBatting to BattingStats; no derivation, parsing, or fallback",
"any divergence between the two record shapes is a compile error here"
],
"convertPitching": {
"intent": "Convert WireBoxPitching to PitchingStats, reconciling wire IP string against wire outs and emitting a WireFieldDiscrepancy if they disagree",
"notes": [
"parses wbpInningsPitched via parseInningsPitched; compares against wbpOuts when both are present",
"on disagreement: emits WireFieldDiscrepancy and prefers the parsed IP string",
"output pitOuts = parseInningsPitched ip <|> wbpOuts (parsed string preferred, wire outs fallback)",
"every other field is a mechanical copy"
]
},
"orEmpty": [
"Maybe Text -> Text helper, falling back to T.empty",
"used to avoid scattering 'fromMaybe T.empty' through the conversion code"
],
"tshow": [
"Show -> Text helper, T.pack . show",
"used in renderWarning to embed numeric ids in messages"
]
}
},
"Pelotero.MLB.Fetch": {
"decisions": [
{
"name": "WireToDomainTeamPrivate",
"what": "wireToDomainTeam is not exported; it is duplicated by hand in Pelotero.Effects.MLBClient",
"why": [
"Exporting it would create a circular concern: Fetch shouldn't be a public utility module, it's the production HTTP path.",
"The conversion is one record literal with four fields; duplicating it is cheaper than the import discipline of having both Fetch and MLBClient depend on a shared private module.",
"If a third caller appears, this should move to a Convert-level helper."
],
"affects": ["wireToDomainTeam"]
},
{
"name": "PayloadShaIsTeamsBytesThenPlayersBytes",
"what": "computeSha hashes the concatenation of teamsBody (raw bytes) followed by playersBody (raw bytes), in that order",
"why": [
"Order matters for stability: if a future fetch reorders the calls, a re-fetch with identical content would produce a different SHA, breaking change-detection.",
"Using the lazy-then-strict transform inside fetchRosters is a one-place implementation detail and not part of the SHA's semantics.",
"Teams-then-players matches the order they're parsed and converted in the same function."
],
"affects": ["fetchRosters", "computeSha"]
}
],
"invariants": {
"fetchRosters": {
"intent": "Real-HTTP roster fetcher: pulls teams and players, parses both, converts to domain, returns FetchedRosters with payload SHA",
"effects": ["IO (HTTP, network)"],
"notes": [
"two HTTP calls happen sequentially, not concurrently; latency is teams + players, not max(teams, players)",
"if either fetch errors at HTTP level, returns Left with the URL in the message",
"if either body fails to parse (Aeson), returns Left with 'Teams parse failure: ...' or 'Players parse failure: ...' prefix",
"warnings from convertPlayers are surfaced in frWarnings; team conversion does not produce warnings",
"frPayloadSha is over the concatenation of raw response bodies"
],
"consumes": [
"Pelotero.Effects.MLBClient.runMLBClientHTTP (production interpreter)"
]
},
"wireToDomainTeam": [