You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(scores): ADR-068 + SCORES_INTEGRATION/BUSINESS_RULES/CLAUDE/CHANGELOG + close audit
Document the picks4all-scores v2 integration: timeline-derived minute-90
scoring, >=3 source confirmation gate, ABD terminal, stale detector +
undecidable-knockout safeguard. New env vars in DEPLOYMENT. CLAUDE
invariant 14. SCORING_RESULTS_AUDIT.md closed (root fix shipped, ADR-068).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopts the scores-service v2 contract and closes the silent-limbo failure mode behind the 30-may Champions final. No schema migration. See **ADR-068**; `SCORING_RESULTS_AUDIT.md` closed.
14
+
15
+
#### Changed
16
+
-**Minute-90 score now derived from the scraper `timeline[]`** (`scoresService/timeline.ts` · `deriveNinetyMinuteScore`) — the `fulltime`/`extratime` fields are always `null` in v2. The `ET` milestone gives the regulation score; penalties (`penaltyHome/Away`) stay separate and never count toward goals90. Fixes extra-time single matches (the final, one-leg knockouts) scoring off the post-ET result.
17
+
-**`ABD` is now terminal** (`FINISHED_STATUSES`) so abandoned matches aren't polled forever; the duplicated local list in `adminService.ts` reuses the canonical constant.
-**Confirmation gate** — finalization to `API_CONFIRMED` requires ≥`SCORES_MIN_CONFIRMATIONS` (default 3) sources on the terminal `timeline[]` milestone (`terminalConfirmationCount`), falling back to live `sourcesAgreeing` for legacy feeds.
22
+
-**Stale detector** (`scoresService/staleDetector.ts`) — throttled scan (`SCORES_STALE_SCAN_INTERVAL_MS`, 5min) for AUTO matches not `COMPLETED` >`SCORES_STALE_THRESHOLD_MS` (210min) after kickoff → one-time admin alert (idempotent via `MATCH_STALE_DETECTED` audit), runs even when the scraper is down.
23
+
-**Undecidable-knockout safeguard** (`structuralAutoPublish.ts`) — authoritative knockout result with no derivable winner (draw without penalties, or penalties tied) → one-time `KNOCKOUT_WINNER_UNDECIDABLE` admin alert.
24
+
- New env vars: `SCORES_MIN_CONFIRMATIONS`, `SCORES_STALE_THRESHOLD_MS`, `SCORES_STALE_SCAN_INTERVAL_MS`.
Work shipped between 2026-05-12 and 2026-05-27 (migrations `20260512_*` through `20260527_*`). The code-level version is unchanged (`backend/package.json` and `frontend-next/package.json` remain `1.0.0`, `BUILD_VERSION` remains `v1.0.0`); these changes land under `[Unreleased]` until the next version bump.
Copy file name to clipboardExpand all lines: CLAUDE.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -92,7 +92,10 @@
92
92
### Results System
93
93
-**Results are scraper-first.** In AUTO mode, picks4all-scores is the primary source. API-Football is fallback only (activates 30min after estimated FT if scraper hasn't reported).
94
94
-**Source hierarchy:** HOST_OVERRIDE > API_CONFIRMED > SCRAPER_PROVISIONAL > HOST_PROVISIONAL > HOST_MANUAL. Higher sources are never overwritten by lower ones.
95
+
-**Scraper contract is v2 (ADR-068):** monotonic state machine (terminal `FT`/`AET`/`PEN`/`ABD` are final), per-match `timeline[]`, fail-closed auth. The `fulltime`/`extratime` fields are always `null` — **minute-90 score is derived from `timeline[]`** (`deriveNinetyMinuteScore`, the `ET` milestone). Penalties never count toward goals90.
96
+
-**Confirmation gate:** finalization to `API_CONFIRMED` requires ≥`SCORES_MIN_CONFIRMATIONS` (default 3) sources on the terminal milestone.
95
97
-**Grace period:** 5 minutes after FT before finalizing a result (configurable via `SCORES_GRACE_PERIOD_MS`).
98
+
-**Stale safety net:** matches not finalized >`SCORES_STALE_THRESHOLD_MS` (210min) after kickoff, and authoritative-but-undecidable knockouts, trigger a one-time admin alert — never fail silent.
96
99
-**Host can override** an existing result only with: mandatory reason, warning shown, and email notification sent to ALL pool members.
97
100
-**Legacy MANUAL mode** instances are exempt (backwards compatibility).
98
101
@@ -216,6 +219,7 @@ CHANGELOG.md # Version history (Keep a Changelog format)
216
219
11.**Welcome email is deferred:**`sendWelcomeEmail` is NEVER called inline from signup or activation handlers. The single trigger surface is `POST /users/me/locale-preference` (happy path) + `welcomeEmailFallbackJob` (24h safety net). `User.welcomeEmailSentAt` is the idempotency key — set inside the same tx that flips `localePromptCompletedAt`. Activation URLs built via `lib/activationUrl.ts` so the email's link points to the locale-correct page (`/activar-cuenta` / `/en/activate-account` / `/pt/ativar-conta`). See ADR-063.
217
220
12.**Locale resolution is URL-prefix-first, then cookie, then Accept-Language, then default.**`next-intl` is configured with `localeDetection: false` AND `localeCookie: false` — it only consults URL prefix and `defaultLocale`. All other signals flow through `frontend-next/src/proxy.ts`. Backend `setAuthCookies` writes `NEXT_LOCALE` when `User.locale` is known (login, google, activate-corporate); `clearAuthCookies` clears it on logout. `POST /users/me/locale-preference` writes the cookie server-side as defensive backup for the client-side write. Never re-enable next-intl's auto-detection without removing the manual logic in `proxy.ts` first. See ADR-064.
218
221
13.**Payment completion runs through `markPaymentCompleted`.** Any code path that needs to mark a `PoolPayment` as `COMPLETED` (Polar webhook, MP sync, MP IPN, either reconciler) MUST call `paymentService.markPaymentCompleted` — never update `poolPayment.status = "COMPLETED"` directly. The function owns the atomic tx (PaymentEvent + PoolPayment + Pool + AccountReceivable + AuditEvent) and the post-tx fan-out (admin notification, CAPI Purchase, GA4 purchase, receipt email). The entry guard makes it fully idempotent. MP sync + IPN share the idempotency key `mp-{id}-approved`; the `source` enum (`POLAR_WEBHOOK` / `MP_SYNC` / `MP_WEBHOOK` / `RECONCILER`) records who claimed it. Polar has `paymentReconcileJob` (advisory lock `82636503n`); MP has `mpPaymentReconcileJob` (advisory lock `82636506n`); both can run concurrently. The MP reconciler auto-completes via the shared function on `approved`; the Polar reconciler flags for human review (intentional asymmetry). See ADR-065.
222
+
14.**Scraper scoring derives from `timeline[]`, never from `fulltime`/`extratime` (always `null`).** The minute-90 / end-of-regulation score comes from `deriveNinetyMinuteScore` (the `ET` milestone of the scores-service `timeline[]`); penalties (`penaltyHome/Away`) are separate and NEVER count toward goals90. Finalization requires ≥`SCORES_MIN_CONFIRMATIONS` (default 3) sources on the terminal milestone (`FT`/`AET`/`PEN`/`ABD`). Because the scraper never closes by time, two safety nets MUST stay wired: the stale detector (`staleDetector.ts`, >`SCORES_STALE_THRESHOLD_MS`/210min → one-time admin alert, idempotent via `MATCH_STALE_DETECTED` audit) and the undecidable-knockout alert (`structuralAutoPublish.ts`, `KNOCKOUT_WINNER_UNDECIDABLE`). Never reintroduce a code path that finalizes or scores a match silently when data is missing. See ADR-068.
Copy file name to clipboardExpand all lines: docs/BUSINESS_RULES.md
+5-1Lines changed: 5 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -371,8 +371,12 @@ Higher-priority sources are NEVER overwritten by lower-priority ones.
371
371
**Scraper-first enforcement (AUTO mode):**
372
372
373
373
- picks4all-scores is the **primary** scoring source. It polls live scores every 15 seconds during matches.
374
-
- After FT, a 5-minute grace period ensures score stability before finalizing as `API_CONFIRMED`.
374
+
- The scraper state machine is **monotonic** (a match never regresses; `FT`/`AET`/`PEN`/`ABD` are terminal). `ABD` (abandoned) counts as finished so it is not polled forever.
375
+
- Before finalizing, the terminal milestone must be confirmed by **≥3 independent sources** (`SCORES_MIN_CONFIRMATIONS`); otherwise polling continues.
376
+
- After a confirmed FT, a 5-minute grace period ensures score stability before finalizing as `API_CONFIRMED`.
377
+
-**Minute-90 score** (for `includeExtraTime=false` phases) is derived from the scraper `timeline[]` (the `ET` milestone), since the legacy `fulltime` fields are no longer populated. Penalties are tracked separately and **never** count toward goals90.
375
378
- API-Football is a **fallback only** — activates 30 minutes after estimated FT if the scraper hasn't reported.
379
+
-**Stale safety net:** a match still not finalized **210 minutes** after kickoff (`SCORES_STALE_THRESHOLD_MS`) triggers a one-time admin alert. A knockout with an authoritative result but no derivable winner (draw without penalties, or penalties tied) also alerts once. (ADR-068.)
376
380
- The host **cannot** publish results from scratch in AUTO mode. Results must come from the scraper or API-Football.
377
381
- The host **can** override an existing confirmed result, but must provide a reason. A warning is shown and an email notification is sent to all pool members.
378
382
- Legacy MANUAL mode instances are exempt from scraper-first enforcement.
**Context:** The scores service was reworked into a **monotonic** state machine (a match never regresses; terminal states `FT`/`AET`/`PEN`/`ABD` are final) and now exposes a per-match `timeline[]` of confirmed milestones. As part of that rework `fulltime*`/`halftime*`/`extratime*` are **always `null`**. Two things broke or were exposed on the Picks4All side:
4929
+
4930
+
1.**Minute-90 score derivation.**`liveScoresJob` computed `homeGoals90/awayGoals90` from `score.fulltimeHome` (`homeGoals90 = wentToExtraTime ? score.fulltimeHome : null`). With that field now always `null`, any single match that goes to extra time (the final, one-leg knockouts) would lose its regulation score, so phases configured `includeExtraTime=false` would score off the post-ET result.
4931
+
2.**Silent limbo.** The 30-may Champions final sat as `SCRAPER_PROVISIONAL 1-1` forever: the old scraper regressed to `NS`, which blocked both scraper finalization and the API-Football fallback, and nobody was alerted (`SCORING_RESULTS_AUDIT.md` §8). The monotonic machine prevents the `NS` regression, but the platform still had no time-based safety net of its own.
4932
+
4933
+
**Decision:** Adopt the v2 contract on the Picks4All side with the timeline as the source of truth for period scores and confirmations, plus our own time-based safety nets (the scraper deliberately never closes by time).
-**Minute-90** (`scoresService/timeline.ts` · `deriveNinetyMinuteScore`): the regulation score = the `ET` milestone's goals (the score with which ET began); `null` when the match never reached ET (then `homeGoals/awayGoals` already are regulation) or when ET was reached but the `ET` milestone is missing (no invented value). Penalties never affect goals90.
4938
+
-**Confirmation gate** (`liveScoresJob`): finalization to `API_CONFIRMED` requires the terminal `timeline[]` milestone to be confirmed by ≥ `SCORES_MIN_CONFIRMATIONS` (default **3**) sources (`terminalConfirmationCount`); below that the match stays `AWAITING_FINISH`. Falls back to live `sourcesAgreeing` when `timeline[]` is absent (legacy feed).
4939
+
-**ABD terminal:**`ABD` added to `FINISHED_STATUSES` so an abandoned match is recognized as over (and routed through the same gate) rather than polled forever. The duplicated local list in `adminService.ts` now reuses the canonical constant.
4940
+
-**Stale detector** (`scoresService/staleDetector.ts`): throttled scan (`SCORES_STALE_SCAN_INTERVAL_MS`, default 5 min) for AUTO matches whose `MatchSyncState` is not `COMPLETED` more than `SCORES_STALE_THRESHOLD_MS` (default **210 min**) after kickoff → one-time admin alert, idempotent via a `MATCH_STALE_DETECTED` audit event; runs even when the scraper is down.
4941
+
-**Undecidable-knockout safeguard** (`structuralAutoPublish.ts`): when a knockout result is authoritative (`API_CONFIRMED`/`HOST_OVERRIDE`) but no winner is derivable (draw without penalties, or penalties tied), a one-time admin alert (`KNOCKOUT_WINNER_UNDECIDABLE`) instead of waiting forever.
4942
+
4943
+
**Decisions taken (owner, 2026-06-02):** stale threshold **210 min** (covers 90' + HT + stoppage + full ET + penalties + margin); confirmation threshold **≥3** sources; the stuck 30-may final is unstuck by a separate one-off host override action, independent of this deploy.
4944
+
4945
+
**Consequences:**
4946
+
- ✅ Single matches with extra time score correctly off the regulation result; the limbo failure mode is now caught and surfaced within ~210 min even if every automatic path fails.
4947
+
- ✅ No schema migration — idempotency uses audit events, not a new `MatchSyncStatus`.
4948
+
- ⚠️ A match the scraper can never confirm with 3 sources won't auto-finalize via the scraper; it relies on the API-Football fallback and, failing that, the stale alert + a manual override. Intentional (correctness over speed).
4949
+
- ⚠️ `STALE_THRESHOLD = 210 min` assumes no legitimate match runs longer; a rare long suspension would alert. Acceptable — an alert, not an auto-action.
0 commit comments