Skip to content

Commit c2bfa01

Browse files
Rastipunkclaude
andcommitted
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>
1 parent 1ccf261 commit c2bfa01

8 files changed

Lines changed: 169 additions & 7 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1
88

99
## [Unreleased]
1010

11+
### picks4all-scores v2 integration + scoring/finalization hardening (2026-06-02)
12+
13+
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.
18+
- **Typed scores HTTP errors**`ScoresServiceError` (`isUnavailable` 503 / `isAuthError` 401·403 / `isRateLimited` 429 + `Retry-After`).
19+
20+
#### Added
21+
- **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`.
25+
1126
### Sales (cuenta de cobro), locale resolution, payment observability + parity
1227

1328
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.

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@
9292
### Results System
9393
- **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).
9494
- **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.
9597
- **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.
9699
- **Host can override** an existing result only with: mandatory reason, warning shown, and email notification sent to ALL pool members.
97100
- **Legacy MANUAL mode** instances are exempt (backwards compatibility).
98101

@@ -216,6 +219,7 @@ CHANGELOG.md # Version history (Keep a Changelog format)
216219
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.
217220
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.
218221
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.
219223

220224
---
221225

SCORING_RESULTS_AUDIT.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,35 @@ the scraper goes silent / reverts to a non-terminal status:
279279
4. Consider a time-based finalize of an existing provisional score once
280280
well past expected end, with the source marked accordingly.
281281

282+
## 10. Resolution — SHIPPED (2026-06-02)
283+
284+
Root fix (§9-B) implemented as the picks4all-scores v2 integration
285+
(**ADR-068**), on branch `feat/scraper-integration`:
286+
287+
1. **`NS` regression can't recur** — the scores service is now monotonic
288+
(terminal states are final); the platform also never synthesizes `NS`
289+
from missing data (a fixture absent from `matches[]` is skipped, not
290+
reset).
291+
2. **Minute-90 from `timeline[]`** (`deriveNinetyMinuteScore`) — the
292+
empate+penales case (1-1 → PEN) now stores `goals90 = 1-1`,
293+
`penalties = 4-3` correctly, instead of reading the now-`null`
294+
`fulltime` fields.
295+
3. **Confirmation gate** — finalization needs ≥3 sources on the terminal
296+
milestone.
297+
4. **Stale detector** (`staleDetector.ts`, >210 min) + **undecidable-
298+
knockout safeguard** (`structuralAutoPublish.ts`) — the silent-limbo
299+
class of bug now produces a one-time admin alert. **Never fails
300+
silent.**
301+
302+
The §9-A operational unblock of the 30-may final (host override) is
303+
tracked separately as a one-off action.
304+
305+
> **Status: CLOSED.** Further detail in `SCRAPER_INTEGRATION_PLAN.md` and
306+
> `docs/guides/SCORES_INTEGRATION.md`.
307+
282308
## Document version
283309
- v1 — 2026-06-01 — code audit complete; awaiting prod data (§5).
284310
- v2 — 2026-06-02 — root cause confirmed (§8): scraper reverted to `NS`,
285311
blocking both finalization and the API-Football fallback; result
286312
frozen at SCRAPER_PROVISIONAL. Fix proposed (§9).
313+
- v3 — 2026-06-02 — root fix shipped (§10, ADR-068). Audit closed.

SCRAPER_INTEGRATION_PLAN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,8 @@ cierra el partido. El scraper no reabrirá ese partido (brief §6.7).
113113

114114
## Versión
115115
- v1 — 2026-06-02 — plan unificado brief-scraper + audit-scoring, pendiente "Go".
116+
- v2 — 2026-06-02 — **EJECUTADO.** Commits 1-6 en `feat/scraper-integration`
117+
(cliente+timeline, goals90, ABD+confirmaciones≥3, detector de atasco,
118+
dedup FINISHED_STATUSES, docs). Formalizado en **ADR-068**;
119+
`SCORING_RESULTS_AUDIT.md` cerrado (§10). Pendiente: acción única §4
120+
(desatascar la final del 30-may por override, en dry-run).

docs/BUSINESS_RULES.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,12 @@ Higher-priority sources are NEVER overwritten by lower-priority ones.
371371
**Scraper-first enforcement (AUTO mode):**
372372

373373
- 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.
375378
- 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.)
376380
- The host **cannot** publish results from scratch in AUTO mode. Results must come from the scraper or API-Football.
377381
- 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.
378382
- Legacy MANUAL mode instances are exempt from scraper-first enforcement.

docs/DECISION_LOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4921,4 +4921,36 @@ Leg (B) was broken in half. `PATCH /admin/sales/account-receivables/:id/status
49214921

49224922
---
49234923

4924+
## ADR-068: picks4all-scores v2 contract — timeline-derived scoring, confirmation gate, stale detection
4925+
4926+
**Date:** 2026-06-02 | **Status:** Accepted
4927+
4928+
**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).
4934+
4935+
**Implementation:**
4936+
- **Client** (`scoresService/client.ts`): `LiveScore.timeline?: TimelineEvent[]`; typed `ScoresServiceError` (`isUnavailable` 503 / `isAuthError` 401·403 / `isRateLimited` 429 + `Retry-After`).
4937+
- **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.
4950+
4951+
**Related code:** `backend/src/services/scoresService/{client,timeline,staleDetector}.ts`, `backend/src/jobs/liveScoresJob.ts`, `backend/src/services/apiFootball/types.ts`, `backend/src/services/structuralAutoPublish.ts`, `backend/src/lib/constants.ts` (`SCORES`).
4952+
**Spec / audit:** `SCRAPER_INTEGRATION_PLAN.md`, `SCORING_RESULTS_AUDIT.md`.
4953+
4954+
---
4955+
49244956
**END OF DOCUMENT**

docs/guides/DEPLOYMENT.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ All rate limit values are configurable via environment variables:
235235
| `SCORES_TRACK_WINDOW_HOURS` | Hours ahead to register fixtures | `24` |
236236
| `SCORES_GRACE_PERIOD_MS` | Grace period after FT before finalizing | `300000` |
237237
| `SCORES_FALLBACK_DELAY_MS` | Delay before API-Football fallback | `1800000` |
238+
| `SCORES_MIN_CONFIRMATIONS` | Min sources confirming the terminal milestone to finalize | `3` |
239+
| `SCORES_STALE_THRESHOLD_MS` | Age after kickoff a non-finalized match is "stale" (admin alert) | `12600000` |
240+
| `SCORES_STALE_SCAN_INTERVAL_MS` | Stale-detector scan cadence | `300000` |
238241
| `FIXTURE_TRACKING_CRON` | Fixture registration cron schedule | `0 * * * *` |
239242

240243
#### Analytics — Google Analytics 4 Measurement Protocol (server-side)

0 commit comments

Comments
 (0)