From d8f0bfa905d3b5d7aa88df4ac242e4bf28e563cd Mon Sep 17 00:00:00 2001 From: "sharp-resolver[bot]" <3736210+sharp-resolver[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:51:05 -0400 Subject: [PATCH] docs(audit): annotate /odds/export as internal-only in reality-audit 2026-06-26 (crew #2423) --- DOCS_REALITY_AUDIT_2026-06-26.md | 219 ++ DOCS_REALITY_AUDIT_2026-06-26_findings.json | 3361 +++++++++++++++++++ DOCS_REALITY_AUDIT_2026-06-26_followup.json | 1660 +++++++++ 3 files changed, 5240 insertions(+) create mode 100644 DOCS_REALITY_AUDIT_2026-06-26.md create mode 100644 DOCS_REALITY_AUDIT_2026-06-26_findings.json create mode 100644 DOCS_REALITY_AUDIT_2026-06-26_followup.json diff --git a/DOCS_REALITY_AUDIT_2026-06-26.md b/DOCS_REALITY_AUDIT_2026-06-26.md new file mode 100644 index 0000000..96683dd --- /dev/null +++ b/DOCS_REALITY_AUDIT_2026-06-26.md @@ -0,0 +1,219 @@ +# SharpAPI Docs ↔ `sharp-api-go` Reality Audit — current state (2026-06-26) + +> ⚠️ **SUPERSEDED (#1158) — `/api/v1/odds/export` is internal-only:** Since commit `356d989` (PR #1159, "fix: restrict /api/v1/odds/export to internal keys only"), the route is wrapped `internalOnly(oddsExportHandler)` at `routes.go:85` — it 404s any non-internal/customer API key. It must **NOT** be documented or added to the public `openapi.json`. Every "GA / ungated / all-tier / document `/odds/export`" mention below is **stale**. (Governing decision: `DOCS_FIX_PROGRESS_2026-06-26.md` line 7 — "do NOT document ungated/unannounced endpoints (`/odds/export`, `/injuries`, undocumented `/historical/*`) — none of those.") See also the **openapi.json regen guard** note in §Undocumented / phantom endpoints. + +> Read-only audit. No docs were modified. **Working deliverable — do not `git add` into the docs repo.** +> Reality baseline: `/root/sharpapi.io/sharp-api-go` (routes.go authoritative) + **live API on OVH `:3003`** (ground truth; envelopes/fields re-curled). Docs target: `docs.sharpapi.io` branch `docs/265-c8594804-audit-fixes` (`origin/main` + the #265 fix commit). +> Method: 27 parallel sub-audits (one per endpoint/area), each grounded in `gofile:line` + a live read; CRITICAL/HIGH findings were queued for an independent adversarial re-check. This run **hit the account monthly spend limit near the end**, so the adversarial-verify pass and 7 audit units did not complete — see **§Coverage gaps**. + +--- + +## TL;DR + +The docs remain **structurally drifted from the live response contract**, and essentially **nothing from the 2026-06-21 audit's systemic findings has been fixed** (0 of the re-checked findings show `fixed-since-prior`; commit `9a9af03` only landed 4 narrow doc gaps). The dominant defect is unchanged and confirmed live on **every endpoint family**: the docs advertise a `{success, meta:{…}}` response envelope (the retired TS/Hono server's shape) while the Go API returns one of three flat envelopes — this alone breaks copy-paste JS/Python on ~18 pages. Layered on top: opportunity/closing/teams/markets **field-name and field-shape drift**, a near-universal **`401` error-code mislabel** (`unauthorized` vs the real `missing_api_key`/`invalid_api_key`), **silently-broken documented query params** (`fields`, `sort`, `min_odds`, `min_size`, `format=csv`, futures `market`), **tier mislabels** (Sharp "All" books = really 25; Historical "Sharp+" = really Enterprise-only), and **spec drift** (openapi.json is a month stale, missing 9 live endpoints; the version sidecar reports the wrong version). New this run: a **CRITICAL auth-correctness bug** — the Reference Data pages tell customers the endpoints are public (`Auth: No`) when all five require an API key. + +**Counts:** 16 CRITICAL · 64 HIGH (1 is a junk placeholder → 63 real) · 88 MEDIUM · 72 LOW = **239 real findings**. Status: **208 still-present**, **30 new**, 2 not-a-bug. Every English finding replicates into **de/es/pt-BR** (≈4× remediation). **7 doc areas were not audited this run** (top-level/pricing, concepts, SDKs, examples, streaming guides, asyncapi.yaml, i18n) — coverage is ~75%. + +--- + +## Scoreboard + +| Severity | still-present | new | not-a-bug | total | +|---|---|---|---|---| +| CRITICAL | 15 | 1 | 0 | 16 | +| HIGH | 56 | 7 | 0 | 63* | +| MEDIUM | 73 | 14 | 1 | 88 | +| LOW | 64 | 8 | 1 | 72† | +| **Total** | **208** | **30** | **2** | **239** | + +\* excludes 1 junk placeholder (`gamestate` H42 — that unit was cut off by the spend limit). † `not-a-bug` rows are corrections, listed in §Not-a-bug. + +**By category:** field-missing 49 · field-name 41 · errcode 30 · envelope 29 · example-breaks 22 · param-noop 19 · tier 13 · pagination 9 · missing-endpoint 8 · conceptual 8 · openapi-drift 5 · auth 2 · rate-limit 2 · other 3. + +--- + +## Systemic themes (fix the theme, not the 239 leaves) + +**S1 — Response-envelope drift (`{success, meta:{…}}` → flat).** ~29 envelope findings + ~22 example-breaks across `odds`, `odds-delta`, `odds-best`, `odds-comparison`, `odds-batch`, all five `opportunities-*`, `events`, `events-by-id`, `events-odds`, `events-markets`, `sports`, `leagues`, `markets`, `sportsbooks`, `teams`, `account-usage`, `health`. The docs wrap data in `success`+`meta`; the API returns one of **three** real shapes — do not collapse them: + 1. flat non-paginated `{data, updated_at}` → sports, leagues, markets, sportsbooks, events/:id, events/:id/odds, events/:id/markets, middles-summary + 2. flat paginated `{data, pagination:{limit,offset,count,total,has_more,next_offset,next_cursor}, updated_at}` → odds*, opportunities/*, events, teams, splits, account, account/usage + 3. **wrapped `{success, data, meta}`** (legit exception, keep it) → account/keys, historical/*, splits/history + Every JS `const {data, meta} = …` / Python `result['meta'][…]` snippet on a group-1/2 page **throws or prints undefined**. One templated rewrite per envelope class fixes the bulk. + +**S2 — Opportunity field contract is the old schema.** EV uses `game_id/market/game/confidence_score` → real `event_id/market_type/event_name/confidence`; `devig_method` value is UPPERCASE `POWER`. Arb leg `implied_probability` → real `odds_probability`. Middles/low-hold sides documented as nested `odds:{…}` → real **flat** `odds_american/odds_decimal/odds_probability/fair_probability`; low-hold per-side `deep_links` doesn't exist. Middles-summary body is **wholly fictional** (real = `{data:{total, by_sport, by_league}}` with `{count, avg_quality_score, avg_expected_value}` buckets). + +**S3 — `401` error-code mislabel (pervasive).** ~12 pages show `code:"unauthorized"`; auth emits `missing_api_key` (no key) / `invalid_api_key` (bad key). `unauthorized` is reserved for admin Bearer auth. The `docs` URL in these examples is also wrong (`docs.sharpapi.io/en/authentication` → real `sharpapi.io/docs/authentication`). Same class hits 403 bodies: docs show `feature_required` / omit `tier`+`required_tier`; real code is `tier_restricted` with both fields. + +**S4 — Silently-broken documented params (needs an ENG decision, not just a doc edit).** Documented-with-examples but **no-ops** in the handler: `/odds` `fields`, `sort`, `min_odds`/`max_odds`; `/odds/delta` `sort`, `fields`, `min_odds`/`max_odds`; `/opportunities/middles` `min_size`; `/opportunities/arbitrage` `format=csv` (+ whole CSV section); `/futures/odds` `market`; `/futures` `sportsbook`/`limit`/`offset`; `/events` `team`/`has_score`; `/teams` `q`-searches-aliases. Each: **implement** or **de-document** — don't let a doc-only fix silently amputate a feature customers may use. + +**S5 — Tier / auth mislabels.** Sharp "All 32 books" → really **25** (only Enterprise unlimited); Historical "Sharp+ / `closing_lines` feature / 30-day window" → really **Enterprise-only / `history` feature / unlimited**; stream concurrency "10 + `429`/`4029`" → really **1, newer-wins displacement** (no `4029` exists); gamestate "Enterprise-only" → really **`game_state` add-on ($79) OR Enterprise**; deeplinks/batch "Hobby+" → really **no tier gate**; Reference Data "public" → really **API-key-required** (CRITICAL C16). + +**S6 — Machine-spec drift (openapi.json + asyncapi + version sidecar).** `openapi.json` is stamped **2026-05-20 / `13f97e3`** (a month stale) and **omits 9 live endpoints** (`/odds/closing`, all six `/historical/*`, `/odds/export`, `/injuries`, `/odds/historical`, plus base `/opportunities` and the SSE path aliases); its embedded `getHealth` schema is fabricated; its embedded tier table repeats S5. The **`openapi-version.json` sidecar reports `2.1.0` while the spec is `3.1.0`** — the very signal consumers poll to detect spec changes is wrong (and the stamp is stale). + +**S7 — 4× i18n multiplication.** Every en finding mirrors verbatim into de/es/pt-BR. Note `9a9af03`'s `_bN` grouping recipe fix was **EN-only** — the other locales still lack it (flagged in that PR; not re-verified this run). + +--- + +## CRITICAL (16) + +All 16 are envelope/shape/auth defects that make documented copy-paste code throw, or misstate auth. `C#` ids are this report's. + +- **C1 `odds-delta`** envelope wholesale-wrong: docs `{success, meta:{count,total,books_changed,pagination,filters}}` → real `{data, removed, pagination, updated_at, meta:{server_time}}`. *(endpoints.go:634-647; live)* +- **C2 `odds-delta`** `meta.books_changed` doesn't exist → JS `.join()` / Python `['books_changed']` throw. *(main.go:950-952)* +- **C3 `odds-best`** wrapped `{success,data,meta}` → real flat `{data,pagination,updated_at}`; `meta.filters` not emitted. *(main.go:7831-7838; live)* +- **C4 `odds-comparison`** docs `{success, data(object), meta}` → real `{data(array), pagination, updated_at}`. *(endpoints.go:255-261; live)* +- **C5 `odds-comparison`** `data` documented as a single nested event object → real is a **flat array** of `{market_type, selection, line, books, book_holds}`. *(endpoints.go:184,256)* +- **C6 `odds-comparison`** JS/Python read `data.markets`/`market.hold`/`sel.books`/`event.event_name` — none exist → runtime throws. *(endpoints.go:169-175)* +- **C7 `opportunities-ev`** docs `{success, meta.summary, meta.filters}` → real flat `{data, pagination, updated_at}`; no summary/avg_ev/by_* anywhere. *(main.go:6984-6988; live)* +- **C8 `opportunities-arbitrage`** docs `{success, meta:{source,summary,filters,books_analyzed}}` → real `{data, pagination, updated_at}`. *(main.go:6984-6988; live)* +- **C9 `opportunities-middles-summary`** wholly fictional body (positive_ev_count/by_market/avg_middle_size/best_ev…) → real `{data:{total, by_sport, by_league}}`, buckets `{count, avg_quality_score, avg_expected_value}`. *(main.go:7097-7186; live)* +- **C10 `events`** Success env `{data, meta:{count,total,pagination,filters}}` → real `{data, pagination, updated_at}`; JS `meta.total`/Python `data['meta']` break. *(main.go:5896-5903; live)* +- **C11 `events-odds`** documented nested `odds:{american,decimal,probability}` doesn't exist → response is **flat** `odds_american/odds_decimal/odds_probability`. *(main.go:585-601; live)* +- **C12 `events-odds`** env documented with `meta`+pagination → real `{data, updated_at}` only; paginate example loops forever. *(main.go:8086-8090; live)* +- **C13 `events-markets`** documented `selections[]` sub-array + Selection Object don't exist → real `MarketResult{market_type, market_label, selection_count, book_count, books[]}`. *(endpoints.go:746-752; live)* +- **C14 `historical-odds-closing`** example iterates `event.books` as array + reads `.lines`/`.book` → real `books` is an **object map**, array is `.odds`; JS/Python throw. *(closing.go:152-169)* +- **C15 `health`** entire envelope fabricated: docs `{status,version,timestamp,checks:{redis,data,schema}}` → real flat `{status, valkey, newest_odds_age_s, version, pinnacle?}`. *(main.go:8218-8233; live)* +- **C16 `overview` (NEW)** Reference Data table marks `/sports`,`/leagues`,`/sportsbooks`,`/markets`,`/teams` as **Auth: No (Public)** — all five require an API key (401 `missing_api_key` without one). Customers will ship keyless calls that 401. *(routes.go:75-79; live)* + +--- + +## HIGH (63 real) — grouped + +**Envelope/example-breaks (same class as CRITICAL, lower blast radius):** H1 `odds` `{success,meta}`→flat · H14/H15 `odds-batch` wrapped→`{data:{events,missing_events},updated_at}` + phantom `meta.found/requested` · H18 `opp-ev` JS reads `meta.count`/`meta.summary.avg_ev` · H23/H24 `opp-middles` `{success,meta}`→flat + nested `odds{}`→flat sides · H26 `opp-lowhold` `{success,meta}`→flat · H27 `events-by-id` `meta:{event_id}`→`updated_at` · H30 `events-markets` `{success,meta}`→`{data,updated_at}` · H32/H33 `sports`/`leagues` fabricated `meta/pagination`→`{data,updated_at}` · H37 `teams` `meta`→`pagination` · H47 `historical-closing` `books` array→object map · H48 `account/usage` shape "almost entirely fictional" · H49 `account-keys` rotate flat fields→`{success,data:{new_key,old_key},meta}` · H56–H59 `websocket` `opportunities_snapshot`/`initial`/`subscribed`/`*:expired` frame shapes wrong (msg.data nesting, `book` not `source`, per-channel ack) · H51/H52 `health` JS reads `health.checks` + openapi `getHealth` schema fabricated. + +**Field-name / field-missing:** H11 `odds-comparison` `books` object→array · H12 `hold` object→`book_holds[]` · H13 best/worst/spread don't exist · H16/H17 `odds-closing` book→`{odds,…}` envelope + undocumented `capture_trigger/is_final/seconds_before_kickoff` (load-bearing CLV fields) · H19 `opp-ev` `game_id/market/game/confidence_score`→`event_id/market_type/event_name/confidence` · H22 `opp-arb` `implied_probability`→`odds_probability` · H31 `events-markets` `type`→`market_type` (+ `market_label`,`books[]`) · H34 `leagues` `name`→`display_name` · H35 `markets` invents `description/hasLine/examples/selection_count` · H39 `teams` `aliases` doesn't exist · H40/H41 `splits` documents DraftKings/Circa sources that return **0 rows** (real = `consensus` + `betmgm`). + +**Error codes / auth / tier:** H5/H8/H9/H28 `401 unauthorized`→`missing_api_key`/`invalid_api_key` (odds, odds-delta, odds-best, events) · H20 `opp-ev` 403 `feature_required`→`tier_restricted` · H36 `sportsbooks` wrong tiers (betrivers/bet365/kalshi/polymarket) · H44/H45/H46 `historical-closing` Sharp→Enterprise, `closing_lines` feature doesn't exist, 403 body wrong on every field · H50 `deeplinks/batch` "Hobby+"→no gate · H53/H54/H55 stream/ws concurrency "10"/`4029`→**1 newer-wins, no 4029**. + +**Param no-ops (ENG decision):** H2 `fields`, H3 `sort`, H4 `min_odds/max_odds` on `/odds` · H10 `odds-comparison` uses deprecated `event=` (sunsets **2026-07-01**, 5 days out) → switch to `event_id` · H21 `opp-arb` `format=csv` + CSV section fictional · H25 `opp-middles` `min_size` no-op · H29 `events-odds` `sportsbook/market/limit/offset` ignored · H38 `teams` `q` doesn't search aliases · H43 `futures/odds` **requires** undocumented `future_id` (bare call → 400). + +**Other:** H6 `odds` Sharp "All 32"→25 · H60 `overview` `retry_after` labeled "seconds" but is **Unix-ms timestamp** (client sleeps ~1.78e12 ms) · **H61–H64 spec missing-endpoints**: `/odds/export` (GA, **ungated**), `/injuries` (GA), all six `/historical/*`, `/odds/closing` — absent from openapi.json. + +--- + +## MEDIUM (88) — by theme (highlights) + +- **Envelope/pagination tails:** `markets`/`sportsbooks` invent `meta/rate_limit/pagination` → `{data,updated_at}`; `account-keys` list `meta` should be `{count,max_keys}`, Create is 200 not 201, Delete nests under `data`; `opp-*` pagination samples omit `count/total/next_cursor`; `odds-best`/`opp-lowhold` `offset max 5000`→effective **500**. +- **Field drift:** `events` undocumented `uuid`/`market_count` + phantom `game_state` + no-op `team`/`has_score`; `teams` `logo` host `cdn.sharpapi.io` (not opticodds), singular `league` not `leagues[]`, no `id`/`live_count`; `odds` omits `is_player_prop`/`market_segment`/`is_stale_pregame_price`; `opp-ev` UPPERCASE `POWER`; `odds-closing` omits CLV devig fields (`no_vig_probability`, `fair_close_decimal`, …); `historical-closing` `meta.source` `valkey:closing_line`→**clickhouse**, `tier_window_days` 30→**unlimited**, phantom `timestamp`. +- **Error codes:** the `401 unauthorized` class repeats on account, odds-batch, opp-arb/ev/lowhold/middles, events-odds; 403 bodies omit `tier`/`required_tier`; `overview` says **21** error codes, real is **23**. +- **Tier/spec:** openapi spec embeds wrong Sharp/Enterprise tier table; nav separator "Historical (Sharp+)"→Enterprise; `sportsbooks` book counts stale (Free "13", "32 books" → 34 live, lists retired Bet365 UK). +- **Stream/SSE:** `stream` reconnect says "fresh snapshot not replay" but **SSE resume is enabled in prod**; `min_ev` default doc 2.0 → handler 0.1; `4003` close code described wrong. +- **deeplinks:** undocumented `resolve` body field + `?state=` redirect suffix; `X-Deep-Link-Type: outcome` header never emitted. + +## LOW (72) — by theme (highlights) + +Cosmetic/stale: `numerical_id` "integer|null"→**omitted** (omitempty) on sports/leagues/markets/sportsbooks; `health.version` "v1"→git SHA, status enum omits `starting`; stale example ids/event_ids that return no data (splits, opp-ev, opp-lowhold); inconsistent book counts (`enterprise.mdx` "43", `index` "32/42", live 34); `X-Request-Id`/`X-RateLimit-*` shown on endpoints that don't emit them; `opp-arb` `warnings` enum `POTENTIALLY_STALE_ODDS`→`LIVE_STALE_ODDS`; ws `unknown_message_type` enum `refresh_token`→`token_refresh`, omits `unsubscribe`; ws `heartbeat`/`pong`/`snapshot:complete`/`odds:removed` example fields drift. **Spec sidecar (S6):** `openapi-version.json` `2.1.0` ≠ spec `3.1.0`; stamps stale 2026-05-20/`13f97e3`; base `/opportunities` + SSE aliases absent from spec. + +--- + +## Undocumented / phantom endpoints & spec drift + +| Endpoint | Reality | Gap | +|---|---|---| +| `GET /odds/export` | ~~GA, **API-key but UNGATED**, flat per-row export~~ → **internal-only (#1158)** — `internalOnly(oddsExportHandler)` at `routes.go:85`; customer key → 404. **Do NOT add to public `openapi.json`.** | ~~No doc page, absent from openapi.json (**H61**)~~ — moot; suppress H61. **Regen guard:** when regenerating `public/openapi.json` from `routes.go`, exclude every `internalOnly(…)`-wrapped handler; `/odds/export` is the only such route as of `origin/main` (2026-07-06). | +| `GET /injuries` | GA (live MLB data), `{data:[…]}` | No doc page, absent from spec (**H62**) | +| `GET /historical/{clv,summary,odds/closing,odds/movements,opportunities,opportunities/:id}` | Enterprise (`history`), wrapped envelope | Only 2 have doc pages; **none** in spec (**H63**) | +| `GET /odds/closing` | Pro+ (`closing_line`), has a doc page | Absent from openapi.json (**H64**) | +| `GET /odds/historical` | Enterprise per-event OLV/CLV | No doc, absent from spec (MED) | +| base `GET /opportunities`, SSE aliases `/stream/{odds,opportunities,all,gamestate,events}` | live | Absent from spec (LOW) | +| openapi `getHealth` schema | fabricated `checks`/`HealthCheck` | Rewrite to flat shape (**H52**) | +| `openapi.json` info.version `3.1.0` vs sidecar `2.1.0` | mismatch | Re-stamp sidecar; spec stale a month (S6) | + +Correctly excluded (internal/adapter-secret, intentionally undocumented): `monitoring/*`, `coverage/*`, `admin/*`, `internal/*`. + +--- + +## Needs an engineering decision (implement vs de-document) + +Documented capabilities the handler **silently ignores** — deleting the docs removes a feature customers may rely on; implementing is code: +`/odds` `fields`, `sort`, `min_odds`/`max_odds` · `/odds/delta` `sort`, `fields`, `min_odds`/`max_odds` · `/opportunities/middles` `min_size` (real lever is `min_profit` on roi%) · `/opportunities/arbitrage` `format=csv` (+ CSV section) · `/futures/odds` `market` · `/futures` `sportsbook`/`limit`/`offset` · `/events` `team`/`has_score` · `/teams` `q`-alias-search. +**Policy:** `/deeplinks/batch` is ungated but docs say Hobby+ — tighten the gate or fix the doc. ~~`/odds/export` is GA and **completely ungated** — confirm that's intended before documenting.~~ → **`/odds/export` is now internal-only (#1158); suppress this item.** + +--- + +## Not-a-bug / corrections (don't re-litigate) + +- **`/futures/odds` filters** `sportsbook`(CSV)/`sport`/`league` ARE supported — document them (only `market` is the no-op). +- **`book_unavailable` / `cursor_expired`** error codes exist **only in unmerged worktrees** (`.claude/worktrees/**`), not shipped code — the authoritative checkout has **23** codes, so "docs say 21, add those 4" from the prior audit is wrong; add only `book_not_selected` + `invalid_filter`. +- Verified-correct elsewhere (per prior audit, not re-flagged this run): `/odds/closing` tier = **Pro+** (do NOT change to Enterprise); `/events` `limit` max **200** / `offset` max **5000** (different cap than odds-family 500); EV `player_id` is real; EV% and arbitrage math match the engines; `historical-clv` body already says Enterprise. + +--- + +## Coverage gaps (NOT audited this run — spend limit) + +Re-run these 7 units when budget resets (they likely carry the same S1–S3 drift, especially in code samples): +1. **top-level** — `index`, `quickstart`, `authentication`, `pricing` (prices, Sharp "25", Game State $79 add-on likely missing, historical=Enterprise rows). *Prior audit: H11, H23, M19, M20, L11, L12.* +2. **concepts** (9 pages) — `ev-calculation` (`threshold`→`min_ev`), `event-matching` (`_bN`/`_g{N}`), `live-vs-prematch`, `entity-reference-ids`, `polymarket-resolution`, `pinnacle-odds-changed-at`, etc. +3. **sdks** (`python`, `typescript`, `mcp`, `other-languages`) — package names + example envelopes. +4. **examples** (`arbitrage-scanner`, `value-betting`) — end-to-end samples on the S2 field contract. +5. **streaming guides** (`overview`, `single-connection`, `websocket`) — re-confirm the `4029`/limit-1 wording. +6. **asyncapi.yaml** — WS/SSE channel + frame schemas vs `websocket.go`/`sse.go`. +7. **i18n** — quantify the de/es/pt-BR replication + the EN-only `_bN` fix gap. +Also **`gamestate`** returned only a junk placeholder (unit cut off) — re-audit (expect snake_case `in_play` fields + `game_state` add-on gating). + +--- + +## Prioritized remediation plan + +1. **Auth-correctness first — C16** (`overview` Reference Data "public"→"API key required"). One-line table fix, but it makes customers ship broken keyless calls. 4 locales. +2. **Envelope rewrite (S1)** — template the 3 real envelopes; rewrite the ~18 affected pages' JSON sample + field table + JS/Python. Highest customer impact; mechanical once templated. ×4 locales. +3. **Opportunity field contract (S2)** — rename EV fields, flatten middles/low-hold sides, rewrite middles-summary, `implied_probability`→`odds_probability`, UPPERCASE `POWER`. +4. **Error-code sweep (S3)** — global `unauthorized`→`missing_api_key`/`invalid_api_key`, fix `docs` URLs, 403 `feature_required`→`tier_restricted` + `tier`/`required_tier`, `retry_after` is Unix-ms. +5. **Tier/limit fixes (S5)** — Sharp 25; Historical→Enterprise + drop `closing_lines`/30-day; stream limit 1/newer-wins + delete `4029`; gamestate add-on; deeplinks gate; sportsbook tiers. +6. **Param truth (S4)** — route each no-op to ENG (implement vs delete); **urgent: `odds-comparison` `event=`→`event_id` before the 2026-07-01 sunset.** +7. **Spec drift (S6)** — regenerate `openapi.json` from current routes (adds the 9 missing paths, fixes `getHealth` + tier table), re-stamp `openapi-version.json` (→3.1.0), bump per CONTRIBUTING policy. +8. **Document the undocumented** — ~~`/odds/export` (confirm ungated intent),~~ `/injuries`, the `/historical/*` family. (`/odds/export` is internal-only since #1158 — do not document it.) +9. **i18n backfill (S7)** — propagate all of the above ×3 locales; close the EN-only `_bN` gap. +10. **Re-run the 7 uncovered units** + `gamestate` when budget resets. + +--- + +# ADDENDUM — Reconciliation pass (same day, 2026-06-26) + +A second workflow (a) covered the 7 units run-1 missed + re-audited `gamestate` + re-ran the endpoint census, and (b) **adversarially re-verified all 79 run-1 CRITICAL/HIGH findings** against a fresh **`origin/main`** worktree + live API. (Run-1's local checkout turned out to be **261 commits behind `origin/main`** — substance held because finders cross-checked live, but code line-numbers in the body above should be taken from `origin/main`.) + +## Verify outcome — run-1 CRITICAL/HIGH +**78 confirmed · 1 adjusted · 0 refuted** (79 total). The run-1 CRITICAL/HIGH set is essentially fully validated. +- **Adjusted:** *deeplinks/batch "Hobby+" gate* **HIGH → MEDIUM** — reality (no tier gate, all authenticated tiers incl. Free) is *more permissive* than the doc claims, i.e. docs under-promise; no customer is denied a promised capability and there's no entitlement leak, so it's a lower-severity accuracy gap. Still a real fix. + +## New reality the stale checkout hid (methodology correction) +- `origin/main` has a **`steam` feature** (Pro+) and a live **`GET /api/v1/injuries`** route (Enterprise, `injuriesHandler`) — neither is in any doc, the openapi spec, or run-1's brief. +- All code citations in this addendum reference the `origin/main` worktree. + +## New grand totals (run-1 + gaps, post-reconciliation) +| Severity | run-1 (verified) | gap units | **total** | +|---|---|---|---| +| CRITICAL | 16 | 7 | **23** | +| HIGH | 62 | 22 | **84** | +| MEDIUM | 89 | 25 | **114** | +| LOW | 72 | 17 | **89** | +| **Total** | **239** | **71** | **310** | + +Coverage is now **100%** of the doc surface (all api-reference, concepts, sdks, examples, streaming guides, top-level, openapi.json, asyncapi.yaml, openapi-version.json, i18n). + +## Gap-unit CRITICAL (7) — new + +- **GC1 `sdks/python`** account example reads `info.key['tier']` / `info.limits` / `info.features.ev` — none exist; real `/account` = `{data:{key_id, tier, rate_limit:{requests_per_minute,max_books}, features:[…], streaming:{…}}, updated_at}`. The Python SDK model itself is mis-shaped. *(endpoints.go:1667-1681; sharpapi-python models.py:649)* +- **GC2 `examples/arbitrage-scanner`** SSE handler reads `data['data']['opportunities']` — no `data` wrapper exists; `:detected` frame is top-level `{opportunities:[…],count,type}`. KeyErrors on every event. *(streaming.go:2604-2614)* +- **GC3 `examples/arbitrage-scanner`** listens for SSE event `arbitrage` — stream emits **`arb:detected`** (+`arb:expired`); the handler never fires. *(streaming.go:2717-2730)* +- **GC4 `examples/arbitrage-scanner`** listens for `initial` — connect-time dump is event **`snapshot`** with `{arbitrage:[…],count,total,offset,has_more}`; no `data.opportunities`. *(sse.go:1326-1407)* +- **GC5 `examples/value-betting`** JS reads `JSON.parse(e.data).data.opportunities` — `ev:detected` frame is top-level `{opportunities:[…],count,type}`, so `.data` is undefined → TypeError. *(streaming.go:2604-2612)* +- **GC6 `streaming/websocket`** Close Codes table lists **`4029`** — doesn't exist in the server (over-limit = newer-wins 4001 displacement / HTTP 429 pre-upgrade). *(websocket.go:156,164,3434)* +- **GC7 `gamestate`** `in_play` documented camelCase (`currentPitcher`/`currentBatter`) + a "fields are camelCase" callout — server emits **snake_case** (`pitcher`/`batter`/…). *(gamestate_inplay.go:151-156)* + +## Gap-unit HIGH (22) — new (grouped) + +- **Concepts:** `ev-calculation` `threshold` param is a no-op (real `min_ev`) + "default 2% EV" is really **0.1%**; `live-vs-prematch` lists **Novig/Polymarket at Free** (both Hobby; Free whitelist is dk+fd only); entity-reference-ids logo host `cdn.opticodds.com`→`cdn.sharpapi.io`. +- **SDKs:** middles `mid.side1.odds.american` (real flat `odds_american`); TS `LowHoldSide` nested `odds{}` (real flat); EV `confidence_score`→`confidence`; TS EV `game`/`market`→`event_name`/`market_type`; MCP page Sharp "All 32"→25 + `find_low_hold` "All"→Pro. +- **Examples:** value-betting `opp.game`→`opp.event_name`. +- **Streaming guides:** `4003` mislabeled "no streaming access" (real = entitlements-changed; no-access is HTTP 403 pre-upgrade); `min_ev` default "2.0"→0.1. +- **AsyncAPI (`public/asyncapi.yaml`):** `*:expired` puts `expired` top-level not under `data`; `opportunities_snapshot` nests arrays under `data` (one opType/frame); `initial` uses `book` not `source` (no `count`); **`global_seq` is a STRING** (spec says integer); channel enum omits `gamestate`/`closing_line`/`all`/`opportunities`/`steam`. +- **i18n:** event-matching `_bN`/`_g{N}`/grouping/live-share sections are **EN-only** (de/es/pt-BR 141 lines vs EN 215; non-EN ID examples won't match the API); the "New (May 2026)" nested-reference-blocks + `numerical_id`/`abbreviation` field docs exist in 10 EN pages, missing from all locales; the fabricated **`4029`** is replicated verbatim into all 3 locales. +- **Gamestate:** `in_play` documented baseball-only camelCase — really a **sport-aware** block for every live sport (`period`/`period_number`/`clock`/`time_min`/`time_sec`/`is_clock_stopped`/`last_play`). +- **Census-v2 (origin/main):** **`GET /injuries`** (Enterprise) undocumented + absent from openapi.json; ~~**`GET /odds/export`** (all-tier, streaming bulk) undocumented + absent from spec~~ → **internal-only since #1158; suppress.** + +## Gap-unit MEDIUM/LOW (42) — themes +Mostly extend the same systemic classes into the newly-covered surface: SDK examples carrying the envelope/field drift; asyncapi field/enum mismatches (`seq` semantics, missing control frames); i18n replication of MEDIUM/LOW en findings; concepts prose with stale tier/book claims; openapi-version sidecar (`2.1.0` vs `3.1.0`) re-confirmed. Full list in `DOCS_REALITY_AUDIT_2026-06-26_followup.json`. + +## Net effect on the remediation plan +No reprioritization — the reconciliation **reinforces** the run-1 plan and adds: (a) **SDK + examples code samples** join the S1/S2 rewrite scope (they break the same way); (b) **asyncapi.yaml** needs a frame-shape + channel-enum rewrite alongside openapi.json (S6); (c) the **i18n backfill (S7)** is now quantified — locale event-matching pages and the "New (May 2026)" field blocks need porting, and `4029` must be killed in all 4 locales; (d) document **`/injuries`** ~~and **`/odds/export`**~~ (`/odds/export` internal-only since #1158 — do not document). diff --git a/DOCS_REALITY_AUDIT_2026-06-26_findings.json b/DOCS_REALITY_AUDIT_2026-06-26_findings.json new file mode 100644 index 0000000..7a7e0a8 --- /dev/null +++ b/DOCS_REALITY_AUDIT_2026-06-26_findings.json @@ -0,0 +1,3361 @@ +{ + "_annotation_2026_07_06": "SUPERSEDED: /api/v1/odds/export is internal-only since PR #1159 (commit 356d989). routes.go:85 wraps it internalOnly(oddsExportHandler) — customer API key -> 404. All findings below that recommend documenting /odds/export or adding it to public/openapi.json are STALE. The routes.go line number cited (routes.go:71) is also stale — the route is now at line 85 with the internalOnly wrap. Suppress H61 (findings.json) and the census-v2 /odds/export entry (followup.json). Governing decision: DOCS_FIX_PROGRESS_2026-06-26.md line 7. See crew task #2423.", + "stats": { + "units": 27, + "total_findings": 240, + "critical": 16, + "high": 64, + "medium": 88, + "low": 72, + "still_present": 208, + "new": 30, + "fixed_since_prior": 0, + "refuted": 0 + }, + "report": null, + "findings": [ + { + "title": "Response envelope documented as {success, meta:{...}} but API returns flat {data, pagination, updated_at}", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Success (200) example (lines 184-251) shows `{ \"success\": true, \"data\": [...], \"meta\": { count, total, books_available, books_returned, pagination:{...}, updated_at, filters } }`.", + "reality": "oddsHandler serializes OddsResponse{Data, Pagination, UpdatedAt} = flat `{data, pagination, updated_at}`. No `success`, no `meta`, no `books_available`/`books_returned`/`filters`. `count`/`total`/`has_more`/`next_offset`/`next_cursor` live inside `pagination`, not `meta`.", + "evidence": "main.go:5029-5033 (resp := OddsResponse{Data,Pagination,UpdatedAt}); main.go:921-925 (struct); live: /odds?sportsbook=pinnacle&sport=baseball&limit=2 -> top-level keys [data, pagination, updated_at], no success/meta", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the 200 example + response field section to flat {data, pagination, updated_at}; delete `success` and all `meta.*` fields; move count/total into the pagination object. (Prior audit envelope finding, group-2 endpoint.)", + "locale_scope": "Replicates to de, es, pt-BR (same source).", + "unit": "odds" + }, + { + "title": "`fields` (field selection) param is a silent no-op on /odds", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 38, 448-470: `fields` param + a 'Field Selection' section claiming `?fields=id,sportsbook,odds_american` returns only those keys.", + "reality": "oddsHandler (and fast/multi-fast/group_by paths) never read `fields`; it is not parsed in pkg/filters at all. Every row is fully serialized.", + "evidence": "no `fields` read in pkg/filters/parse.go or oddsHandler; live: /odds?...&fields=id,sportsbook,odds_american -> row returned all 33 keys (id,sportsbook,event_id,...,sportsbook_ref,external_event_id)", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the Field Selection section + `fields` query param, or implement projection. (Prior audit H1, still unfixed.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "`sort` param is a silent no-op on /odds (results are always chronological)", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 37, 428-446: `sort` param + a 'Sorting' section with examples `sort=-odds_american`, `sort=odds_probability`, `sort=event_start_time`, and a 'Valid sort fields' list.", + "reality": "oddsHandler always sorts via byOddsChronological (event_start ASC -> event_id -> sportsbook -> market_type -> id). `res.Filters.Sort` is only consumed by eventsHandler (main.go:5747) and opportunitiesHandler (main.go:6444), never by oddsHandler.", + "evidence": "main.go:4976 sort.Sort(byOddsChronological(matched)); main.go:5089-5105 fast-path uses same chronological key; live: /odds?...&sort=-odds_american -> odds order [198,-248,-535,388,276] (chronological, not descending)", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the Sorting section + `sort` param, or implement sorting. (Prior audit H2, still unfixed.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "`min_odds` / `max_odds` range filters are silent no-ops on /odds", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 39-40 and 472-484: `min_odds`/`max_odds` params + an 'Odds Range Filtering' section claiming e.g. `?min_odds=100` returns only +100-and-above, and `?min_odds=-200&max_odds=200`.", + "reality": "min_odds/max_odds are parsed into res.Filters but oddsHandler (all paths) never applies them. The only handler that filters on MinOdds/MaxOdds is opportunitiesHandler (main.go:6465-6473, 6763-6766). /odds returns every row regardless.", + "evidence": "pkg/filters/parse.go:166-167 parses them; no MinOdds/MaxOdds reference in oddsHandler/fast/multi/group_by paths (only main.go:6465-6766 = opportunitiesHandler); live: /odds?...&min_odds=100000 -> count:3 rows with odds [198,-248,-535] (filter ignored; would be 0 if applied)", + "current_status": "new", + "confidence": "high", + "fix": "Remove the Odds Range Filtering section + min_odds/max_odds params from /odds, or implement them. (Prior audit no-op list only named fields & sort.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "401 error code documented as `unauthorized`; API emits `missing_api_key` / `invalid_api_key`", + "severity": "HIGH", + "category": "errcode", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 273-282: 401 body `{ \"error\": { \"code\": \"unauthorized\", \"message\": \"Invalid or missing API key\", \"docs\": \"https://docs.sharpapi.io/en/authentication\" } }`.", + "reality": "Auth emits `missing_api_key` (no key) or `invalid_api_key` (bad key). `unauthorized` is reserved for admin/monitoring Bearer auth. The `docs` URL is also wrong (real = https://sharpapi.io/docs/authentication).", + "evidence": "live (no key): code=missing_api_key, docs=https://sharpapi.io/docs/authentication; live (bad key): code=invalid_api_key, message=\"Invalid API key\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Split the 401 example into missing_api_key / invalid_api_key with correct messages and docs URL. (Prior audit H6, still unfixed.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "Sharp tier book access documented as 'All 32'; reality is capped at 25", + "severity": "HIGH", + "category": "tier", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 539 (and frontmatter line 2 'across 32 books', line 540 Enterprise): Book Access by Tier table marks Sharp = 'All 32'.", + "reality": "tiers.go sets Sharp MaxBooks=25 (deliberate change from -1 to honor the 'Choose any 25' claim). Only Enterprise is -1 (unlimited). Sharp does NOT get all books.", + "evidence": "tiers.go:46-56 (sharp MaxBooks:25, MaxWSBooks:25); tiers.go:57-58 (enterprise MaxBooks:-1)", + "current_status": "still-present", + "confidence": "high", + "fix": "Set Sharp -> '25 / Choose any 25'; keep Enterprise unlimited; update the '32' count if stale. (Prior audit L3/H23, still unfixed.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "group_by=event response shown wrapped in `meta`; API returns flat {data, pagination, updated_at}", + "severity": "MEDIUM", + "category": "envelope", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Event-Grouped Response example (lines 521-527) wraps the result in `meta:{ group_by:'event', books_available:5, filters:{...}, updated_at }`.", + "reality": "The group_by handler returns `{data, pagination:{limit,offset,count,total,has_more,next_offset}, updated_at}` — no `meta`, no `group_by`, no `books_available`, no `filters`. The grouped event objects themselves match (event_id, event_name, sport, league, start_time, is_live, odds).", + "evidence": "main.go:5690-5697 (map{data, pagination, updated_at}); live: /odds?...&group_by=event -> top-level keys [data, pagination, updated_at]", + "current_status": "new", + "confidence": "high", + "fix": "Replace the `meta` wrapper in the group_by example with the flat `{data, pagination, updated_at}` envelope.", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "group_by event_name example uses 'Home vs Away'; API emits 'Away @ Home'", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Line 502: grouped event `\"event_name\": \"PHI 76ers vs PHO Suns\"` (home vs away, ' vs ' separator).", + "reality": "event_name is built as `awayTeam + \" @ \" + homeTeam` — away team first, ' @ ' separator.", + "evidence": "main.go:5670 eventName := eg.awayTeam + \" @ \" + eg.homeTeam; live: /odds?sportsbook=draftkings&sport=baseball&group_by=event -> event_name='Houston Astros @ Detroit Tigers'", + "current_status": "new", + "confidence": "high", + "fix": "Change the example event_name to the 'Away @ Home' form.", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "Odds schema table omits real top-level fields is_player_prop, market_segment, is_stale_pregame_price", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Odds Object Schema table (lines 308-352) lists ~33 fields but does NOT include `is_player_prop`, `market_segment`, or `is_stale_pregame_price`.", + "reality": "Every /odds row carries `is_player_prop` (bool, always present) and `is_stale_pregame_price` (bool, rogervtay SHA-3439) plus `market_segment` (canonical contest-slice, omitempty, also a filter). is_player_prop is also a documented-but-real query filter elsewhere.", + "evidence": "main.go:598 MarketSegment json:\"market_segment,omitempty\"; main.go:668 IsStalePregamePrice json:\"is_stale_pregame_price\"; live pinnacle row keys include is_player_prop, market_segment, is_stale_pregame_price (is_player_prop present on every row in live read)", + "current_status": "new", + "confidence": "high", + "fix": "Add is_player_prop (bool), is_stale_pregame_price (bool), and market_segment (string, optional) to the schema table.", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "Success example shows `total` unconditionally; it is omitempty and only returned with include_total=true", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 233 (and 524 group_by): pagination/meta shows `\"total\": 3095` as if always present.", + "reality": "Pagination.Total is `*int` json:\"total,omitempty\" and only set when `include_total=true`. Default /odds responses omit `total` entirely (live response has no total key). (group_by always sets total, so that example's total is fine; the flat example's is not.)", + "evidence": "pkg/httputil/httputil.go:18 (Total *int json:\"total,omitempty\"); main.go:5013-5015 (pag.Total only set if includeTotal); live: /odds?...&limit=2 pagination = {limit,offset,count,has_more,next_offset,next_cursor} — no total", + "current_status": "new", + "confidence": "high", + "fix": "Either remove `total` from the flat example, or document `include_total=true` as the param that surfaces it.", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "403 tier_restricted example omits `tier`/`required_tier` and uses wrong docs URL", + "severity": "LOW", + "category": "errcode", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 285-292: 403 body `{ \"error\": { \"code\":\"tier_restricted\", \"message\":\"Sportsbook 'pinnacle' requires Sharp tier or higher\", \"docs\":\"https://docs.sharpapi.io/en/pricing\" } }`.", + "reality": "tier_restricted book denials include `tier` (current) and `required_tier` (e.g. 'sharp') and use docs URL https://sharpapi.io/pricing. The example is missing both detail fields.", + "evidence": "tiers.go:320,336-339 (tier_restricted with tier + required_tier); errcodes/details.go:51,67 (required_tier key); filters_registry.go:86-111 requiredTierForBooks", + "current_status": "still-present", + "confidence": "medium", + "fix": "Add `tier` and `required_tier` to the 403 example and correct the docs URL. (Not live-verified — internal/enterprise key bypasses the gate.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "429 example message wording and docs URL differ; omits real retry_after/tier/limit fields", + "severity": "LOW", + "category": "errcode", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 295-303: 429 body message 'Rate limit exceeded. Retry after 45 seconds.', docs 'https://docs.sharpapi.io/en/authentication#rate-limits'.", + "reality": "Real 429 body: message 'Rate limit exceeded' (no 'Retry after N seconds'), docs 'https://sharpapi.io/docs/rate-limits', plus fields retry_after (unix ms), retryAfter (seconds), tier, limit, remaining; a Retry-After header is also set.", + "evidence": "ratelimit.go:122-133 (message, docs, retry_after=resetMs, retryAfter, tier, limit, remaining)", + "current_status": "new", + "confidence": "high", + "fix": "Align 429 example message + docs URL with code and document retry_after (unix ms) / tier / limit / remaining fields.", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "market_type schema description lists non-canonical example values (spread, total, player_prop)", + "severity": "LOW", + "category": "conceptual", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Line 317: market_type described as '`moneyline`, `spread`, `total`, `player_prop`, etc.'", + "reality": "Canonical market_type values are e.g. `moneyline`, `point_spread`, `total_points`, `player_*`. The bare `spread`/`total`/`player_prop` are category aliases (input only), not emitted market_type values; live rows show `moneyline`.", + "evidence": "doc lines 57-60 alias table (spread->point_spread, total->total_points); live row market_type='moneyline'; Odds struct MarketType main.go:594", + "current_status": "new", + "confidence": "medium", + "fix": "Use real emitted values in the example (point_spread, total_points, player_points) and clarify spread/total/props are input aliases.", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "Documented query params use deprecated aliases `live` and `event` (canonical: is_live / event_id)", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/odds.mdx", + "docs_claim": "Lines 36, 550 use `?live=`; lines 35, 554 use `?event=`.", + "reality": "Canonical params are `is_live` and `event_id`. The `live`/`event` aliases still work but emit RFC 8594 Deprecation headers.", + "evidence": "pkg/filters/parse.go handles is_live/live + event/event_id alias deprecation; applyAliasDeprecationHeaders main.go:4702", + "current_status": "still-present", + "confidence": "medium", + "fix": "Prefer is_live / event_id in params + examples (or note the aliases are deprecated). (Prior audit L2.)", + "locale_scope": "Replicates to de, es, pt-BR.", + "unit": "odds" + }, + { + "title": "Response envelope wholesale-wrong: shows {success, meta:{count,total,books_changed,pagination,filters}} but real is flat {data, removed, pagination, updated_at, meta:{server_time}}", + "severity": "CRITICAL", + "category": "envelope", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "Success (200) JSON shows top-level `success: true` and a `meta` object containing `count`, `total`, `server_time`, `books_changed`, a nested `pagination`, and `filters` (lines 111-153). Schema section (lines 200-209) reinforces this: 'The meta object includes two additional fields' server_time + books_changed.", + "reality": "Real envelope is flat: top-level keys = data, removed, pagination, updated_at, meta, overflow, removed_truncated, since_clamped. There is NO `success` field. `meta` contains ONLY {server_time}. `pagination` is a TOP-LEVEL object {limit, offset, count, total, has_more, next_offset} — not nested in meta. `count`/`total` live in pagination, not meta. There is no `filters` object anywhere.", + "evidence": "endpoints.go:634-647 (OddsResponse{Data,Removed,Pagination,UpdatedAt,Meta:&{ServerTime}}); main.go:921-952; live: /odds/delta?sportsbook=pinnacle&sport=baseball&since=... -> top-level keys ['data','removed','pagination','updated_at','meta','overflow','removed_truncated','since_clamped'], meta={'server_time':...}, has success=False", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the Success (200) JSON block with the real flat envelope: {data:[...], removed:[...], pagination:{limit,offset,count,total,has_more,next_offset}, updated_at, meta:{server_time}}. Remove `success`, the nested meta.{count,total,pagination,filters}. Document the top-level `removed` array (it is always present) and the overflow/removed_truncated/since_clamped flags. Fix the schema table to say meta has ONE field (server_time).", + "locale_scope": "all 4 locales (en/de/es/pt-BR mirror)", + "unit": "odds-delta" + }, + { + "title": "books_changed field does not exist — JS and Python example code throws on copy-paste", + "severity": "CRITICAL", + "category": "example-breaks", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "JS example: `meta.books_changed.join(', ')` (line 71). Python example: `result['meta']['books_changed']` (line 98). Schema table documents `meta.books_changed` as 'List of sportsbook IDs that had updates' (line 209). JSON sample shows `books_changed: ['draftkings']` (line 140).", + "reality": "`books_changed` exists nowhere in the response — not in meta, not at top level. OddsResponseMeta has only ServerTime. The JS `meta.books_changed.join()` throws `TypeError: Cannot read properties of undefined`; the Python `result['meta']['books_changed']` raises `KeyError: 'books_changed'`. Every copy-pasted client crashes on the second poll.", + "evidence": "main.go:950-952 (OddsResponseMeta has only ServerTime); endpoints.go:643; live: meta={'server_time':...}, 'books_changed' in meta = False", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove all `books_changed` references: the schema row, the JSON sample key, the JS `meta.books_changed.join(', ')` log line, and the Python `result['meta']['books_changed']` print. If a books-changed signal is desired, derive client-side from the data array's `sportsbook` values.", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "Examples chain `since` from meta.server_time but read other nonexistent meta fields; pagination access is also wrong", + "severity": "HIGH", + "category": "example-breaks", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "JS/Python examples destructure `const { data, meta } = await response.json()` then use `meta.server_time` (correct) alongside `meta.books_changed` (broken). The JSON sample shows pagination nested under meta (`meta.pagination`).", + "reality": "`meta.server_time` IS valid (real field), so the since-chaining itself works. But the surrounding example code references the nonexistent `meta.books_changed`, and a reader following the JSON sample would look for `meta.pagination`/`meta.count` which are actually top-level `pagination.count`. The examples mix one real field with several phantom ones.", + "evidence": "odds-delta.mdx:69-74,95-101; main.go:951 (server_time real); endpoints.go:637-643 (pagination is top-level, not under meta)", + "current_status": "still-present", + "confidence": "high", + "fix": "After removing books_changed, leave the `meta.server_time` -> next `since` chaining (it is correct). Where examples read count/total, point them at top-level `pagination.count`/`pagination.total`, not `meta`.", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "401 error documents code `unauthorized`; real codes are `missing_api_key` / `invalid_api_key`", + "severity": "HIGH", + "category": "errcode", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "401 Unauthorized example (lines 189-198) shows `code: \"unauthorized\"`, message 'Invalid or missing API key'.", + "reality": "Auth middleware emits `missing_api_key` when no key is supplied and `invalid_api_key` when the key is bad. `unauthorized` is reserved for admin/monitoring Bearer auth and is never returned by this endpoint. Live no-key/bad-key body uses missing_api_key/invalid_api_key.", + "evidence": "auth.go:1136 (MissingAPIKey), auth.go:1179/1250/1339 (InvalidAPIKey); errcodes.go:23,19,31; live: bad key -> {\"error\":{\"code\":\"invalid_api_key\",...}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Split the 401 example into missing_api_key (no key) and invalid_api_key (bad key), or document the more common one. Remove `unauthorized`.", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "`sort` query parameter is documented but is a no-op on /odds/delta", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "Query Parameters table lists `sort` — 'Sort field with optional - prefix for descending (e.g., -odds_american, odds_probability)' (line 34).", + "reality": "The delta handler never reads a sort field. Results come pre-sorted descending (newest oddsChangedUnix first) from the k-way merge; there is no `res.Filters.Sort` usage in oddsDeltaHandler and no re-sort. `sort` is parsed by the filter pipeline but ignored here.", + "evidence": "endpoints.go:472-545 (k-way merge produces newest-first order, comment line 537 'Result is already sorted descending'); no Sort reference in handler; brief: 'sort no-op'", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `sort` row from the Query Parameters table for /odds/delta (or state that delta always returns newest-first and sort is ignored).", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "`fields` query parameter documented but does not trim the response on /odds/delta", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "Query Parameters table lists `fields` — 'Comma-separated field names to include (e.g., id,sportsbook,odds_american)' (line 35).", + "reality": "The delta handler applies no field projection — it appends full *Odds structs to `page` and serializes them whole. Live `fields=id,sportsbook` returned rows with 32 keys (full object), proving the param is ignored.", + "evidence": "endpoints.go:518 (result = append(result, *o) — full struct); no projection before encode at endpoints.go:634; live: /odds/delta?...&fields=id,sportsbook -> row has 32 keys", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `fields` row from the Query Parameters table for /odds/delta, or implement projection. (Verify whether /odds itself honors `fields`; this finding is delta-specific.)", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "`min_odds` / `max_odds` query parameters documented but not applied on /odds/delta", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "Query Parameters table lists `min_odds` ('Minimum American odds filter (e.g., -110)') and `max_odds` ('Maximum American odds filter (e.g., +200)') (lines 36-37).", + "reality": "The delta result-collection loop only filters on sport, league, market, market_segment, live, and event (endpoints.go:490-515). MinOdds/MaxOdds are parsed into res.Filters but never checked. Live `min_odds=5000` returned rows with odds -426, 307, 388 (all far below 5000), confirming the filter is ignored.", + "evidence": "endpoints.go:490-515 (no MinOdds/MaxOdds check); parse.go:166-167 (parsed but unused in delta); live: /odds/delta?...&min_odds=5000 -> rows with odds [-426,307,388]", + "current_status": "new", + "confidence": "high", + "fix": "Remove `min_odds` and `max_odds` rows from the /odds/delta Query Parameters table, or implement them in the delta loop. (Not flagged in the prior audit for this page.)", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "Phantom `status: \"upcoming\"` field in the data-row example", + "severity": "MEDIUM", + "category": "field-name", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "The success JSON data row includes `\"status\": \"upcoming\"` (line 133).", + "reality": "The `Odds` struct has no `status` field; adapters do not write status (per sharp-api-go CLAUDE.md: 'status is NOT written by adapters'). Live delta rows carry `is_live`/`is_active` instead. No `status` key appears in any live row.", + "evidence": "main.go:585 (type Odds struct — no Status field; awk over struct returns no status json tag); live row keys include is_live/is_active, no 'status'", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `status: \"upcoming\"` line from the JSON example. Use is_live/is_active for state.", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "400 missing-`since` example message and docs field do not match the handler", + "severity": "LOW", + "category": "errcode", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "400 Missing since example (lines 167-176) shows message 'The 'since' parameter is required for the delta endpoint' plus a `docs` field.", + "reality": "Handler returns code validation_error (correct) with message 'since query parameter is required (ISO 8601 timestamp)' and NO `docs` field — only {code, message}.", + "evidence": "endpoints.go:330-336 (error map has only code+message; message text differs)", + "current_status": "still-present", + "confidence": "high", + "fix": "Align the example message to 'since query parameter is required (ISO 8601 timestamp)' and drop the `docs` field from the 400 missing-since example (the offset_too_large example correctly omits docs).", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "Top-level `removed` array (and overflow flag) are undocumented in the main response shape", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/odds-delta.mdx", + "docs_claim": "The Success (200) example and Delta Response Schema describe only `data` + `meta`. `removed` is mentioned only in passing (clamping callouts) and the `overflow` flag is never documented.", + "reality": "Every delta response includes a top-level `removed` array (list of removed odds, each {id, sportsbook, removed_at}) and an `overflow` boolean (true when total>10000, signaling clients to use /odds for a full snapshot). These are first-class fields clients must handle, especially `removed` for incremental sync correctness.", + "evidence": "endpoints.go:635-636,644 (Removed + Overflow); main.go:923,931 (json tags removed/overflow); removedOdd struct main.go:3240-3243; live: top-level 'removed' present (list), 'overflow' present", + "current_status": "still-present", + "confidence": "high", + "fix": "Document the top-level `removed` array (with its element shape {id, sportsbook, removed_at}) as core to delta sync, and document the `overflow` flag alongside removed_truncated/since_clamped.", + "locale_scope": "all 4 locales", + "unit": "odds-delta" + }, + { + "title": "Wrong response envelope: docs show wrapped {success,data,meta}; handler emits flat-paginated {data,pagination,updated_at}", + "severity": "CRITICAL", + "category": "envelope", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "Success (200) sample wraps in {\"success\": true, \"data\": [...], \"meta\": {count,total,pagination:{limit,offset,has_more,next_offset}, updated_at, filters}} (lines 95-158). JS example destructures `const { data, meta } = ...` (line 61).", + "reality": "Handler encodes a flat object with top-level keys data, pagination, updated_at. No `success`, no `meta`. pagination is top-level with keys {limit, offset, count, total, has_more, next_offset}. `filters` echo is NOT emitted.", + "evidence": "main.go:7831-7838 (json.NewEncoder encodes map{\"data\",\"pagination\":Pagination{...},\"updated_at\"}); live: /odds/best?sport=baseball&limit=2 -> TOP-LEVEL KEYS ['data','pagination','updated_at']; has success? False; has meta? False; PAGINATION {limit,offset,count,total,has_more,next_offset}", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the wrapped JSON sample (lines 95-158) with the flat shape {data:[...], pagination:{limit,offset,count,total,has_more,next_offset}, updated_at}. Drop the `meta.filters` echo (not emitted). Fix the JS example (line 61) to destructure `const { data, pagination } = ...` and remove the `meta` reference. Python example already reads result['data'] correctly. Replicate to de/es/pt-BR (all 4 locales identical at lines 95-158).", + "locale_scope": "all 4 locales (en/de/es/pt-BR all show \"success\": true at line 96 and meta block)", + "unit": "odds-best" + }, + { + "title": "401 error code documented as 'unauthorized'; real codes are missing_api_key / invalid_api_key", + "severity": "HIGH", + "category": "errcode", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "401 error body shows {\"error\":{\"code\":\"unauthorized\",\"message\":\"Invalid or missing API key\"}} (lines 172-181).", + "reality": "Auth middleware emits `missing_api_key` (no key) or `invalid_api_key` (bad key). `unauthorized` is reserved for admin/monitoring Bearer auth and is never returned on this customer endpoint.", + "evidence": "errcodes.go (MissingAPIKey/InvalidAPIKey); live: GET /odds/best (no key) -> {\"code\":\"missing_api_key\"}; live: GET /odds/best (bad key) -> {\"code\":\"invalid_api_key\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the 401 sample code to `missing_api_key` (and/or document both `missing_api_key` for no key and `invalid_api_key` for a bad key). Also update the docs URL — live points to https://sharpapi.io/docs/authentication, doc shows https://docs.sharpapi.io/en/authentication (minor). Replicate to all 4 locales (de/es/pt-BR line 175 also show 'unauthorized').", + "locale_scope": "all 4 locales (de/es/pt-BR line 175 = \"code\": \"unauthorized\")", + "unit": "odds-best" + }, + { + "title": "offset documented max 5000; effective cap is 500 (maxOddsOffset)", + "severity": "MEDIUM", + "category": "pagination", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "Query Parameters table: `offset` ... Pagination offset (max 5000) (line 36).", + "reality": "Handler passes MaxOffset: maxOddsOffset (=500) to filters.ParseQuery. The /odds* family cap is 500, not the /events-family 5000. (Handler does not 400 on offset>500; it reads the value and clamps to total, so offsets above 500 still return data but the documented 5000 cap is the wrong number to advertise.)", + "evidence": "main.go:980 (maxOddsOffset = 500); main.go:7638 (MaxOffset: maxOddsOffset in oddsBestHandler ParseQuery); defaults.go:38 (Standard.MaxOffset=500)", + "current_status": "still-present", + "confidence": "high", + "fix": "Change `(max 5000)` to `(max 500)` in the offset row (line 36 / line 35 in locale mirrors). Replicate to all 4 locales (de/es/pt-BR line 35 = max 5000).", + "locale_scope": "all 4 locales (de/es/pt-BR line 35 = máx. 5000 / max. 5000)", + "unit": "odds-best" + }, + { + "title": "all_books[].line documented in schema + JSON sample but never emitted", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "JSON sample shows `\"line\": -6.5` on every all_books[] entry (lines 117-137); schema row `all_books[].line | number | null | Line at this sportsbook` (line 215).", + "reality": "BestOddsBookEntry struct has only Book, Sportsbook, Odds, Edge — no Line field. Live all_books[] entries carry keys book/sportsbook/odds/edge only. (The per-row top-level `line` IS emitted; only the per-book `all_books[].line` is missing.)", + "evidence": "cache.go:411-416 (BestOddsBookEntry{Book,Sportsbook,Odds,Edge}); live: all_books[0] keys = ['book','sportsbook','odds','edge']; has all_books[].line? False", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove `\"line\": -6.5` from each all_books[] object in the JSON sample (lines 119,125,131,137) and delete the `all_books[].line` schema row (line 215). (Or add the field to the struct if intended — but it's currently absent.) Replicate to all 4 locales (de/es/pt-BR line 213 has the all_books[].line row).", + "locale_scope": "all 4 locales (de/es/pt-BR line 213 = all_books[].line row)", + "unit": "odds-best" + }, + { + "title": "Emitted response fields undocumented: all_books[].sportsbook, consensus_odds, market_hold", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "Schema table (lines 196-218) and JSON sample (lines 95-141) do not list `all_books[].sportsbook`, `consensus_odds`, or `market_hold`.", + "reality": "Each all_books[] entry emits BOTH `book` (legacy) and `sportsbook` (the field name /odds rows and /opportunities/* use). The row also emits `consensus_odds` (probability-space average across books, same OddsTriple shape as best_odds) and `market_hold` (the vig/hold for the market). market_segment is also emitted when present (omitempty).", + "evidence": "cache.go:411-416 (Sportsbook json:\"sportsbook\"); cache.go:425,430,431 (MarketSegment, ConsensusOdds, MarketHold); live row has all_books[].sportsbook, consensus_odds:{american,decimal,probability,odds_probability}, market_hold:0", + "current_status": "new", + "confidence": "high", + "fix": "Add schema rows: `all_books[].sportsbook` (canonical book id, mirrors `book`), `consensus_odds` (object: probability-space average odds across books, same shape as best_odds), `market_hold` (number: market hold/vig fraction), and `market_segment` (string, optional contest-slice axis). Show `sportsbook` and the `consensus_odds`/`market_hold` keys in the JSON sample so devs aren't surprised. Applies to all 4 locales.", + "locale_scope": "all 4 locales (handler-emitted, locale-independent)", + "unit": "odds-best" + }, + { + "title": "best_odds probability field-name inconsistency: schema lists `best_odds.probability`, example uses `odds_probability`", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "JSON sample best_odds uses `\"odds_probability\": 0.512` (line 110); schema table row says `best_odds.probability` (line 209).", + "reality": "Both fields ARE emitted (best_odds carries american, decimal, probability, AND odds_probability). `probability` is marked deprecated in code ('deprecated: use odds_probability'). The doc is internally inconsistent (sample vs schema name differ) but neither name is outright wrong.", + "evidence": "cache.go:398-399 (Probability json:\"probability\" // deprecated: use odds_probability; OddsProbability json:\"odds_probability\"); live best_odds keys = ['american','decimal','probability','odds_probability']", + "current_status": "still-present", + "confidence": "high", + "fix": "Align the schema row to the emitted+sample field `best_odds.odds_probability` (and optionally note `best_odds.probability` exists but is deprecated). Same all_books edge note applies. Applies to all 4 locales (de/es/pt-BR line 207 = best_odds.probability).", + "locale_scope": "all 4 locales (de/es/pt-BR line 207)", + "unit": "odds-best" + }, + { + "title": "Response Headers example shows X-RateLimit-Limit: 300 (Pro), misleading for an all-tier endpoint", + "severity": "LOW", + "category": "rate-limit", + "page": "content/en/api-reference/odds-best.mdx", + "docs_claim": "Response Headers block shows `X-RateLimit-Limit: 300` (line 163) — i.e. the Pro tier limit — for an endpoint documented as available to all tiers including Free.", + "reality": "Rate limit is per-tier: free 12, hobby 120, pro 300, sharp 1000, enterprise 10000. A Free user (the tier the Authentication callout explicitly highlights) would see X-RateLimit-Limit: 12, not 300. The example value isn't wrong for Pro but contradicts the page's all-tiers/Free framing.", + "evidence": "tiers.go rate limits (free 12 / hobby 120 / pro 300 / sharp 1000 / enterprise 10000); odds-best.mdx:163 shows 300", + "current_status": "still-present", + "confidence": "medium", + "fix": "Either annotate the header example as 'Pro tier shown; value varies by tier (Free 12, Hobby 120, Pro 300, Sharp 1000, Enterprise 10000)' or drop the hardcoded number. Cosmetic — degrades gracefully.", + "locale_scope": "all 4 locales (header block is copy-identical)", + "unit": "odds-best" + }, + { + "title": "Response envelope is wrong: doc shows {success, data, meta}; reality is {data, pagination, updated_at}", + "severity": "CRITICAL", + "category": "envelope", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Success (200) body is {\"success\": true, \"data\": {...event object...}, \"meta\": {\"market_filter\": null, \"updated_at\": ...}} (lines 98-195).", + "reality": "Handler encodes map{\"data\": result, \"pagination\": Pagination{Count}, \"updated_at\": getStoreTimeISO()}. There is NO `success` boolean and NO `meta` object. Live: returns {\"data\":[...],\"pagination\":{\"limit\":0,\"offset\":0,\"count\":1,\"has_more\":false,\"next_offset\":null},\"updated_at\":\"2026-06-27T00:33:09.663894932Z\"}.", + "evidence": "endpoints.go:255-261; live: /odds/comparison?event_id=mlb_astros_detroit_2026-06-26_b2&market=moneyline -> {data:[...],pagination:{...},updated_at:...} (no success/meta)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the JSON sample envelope with {\"data\": [...], \"pagination\": {\"limit\":..,\"offset\":..,\"count\":..,\"has_more\":..,\"next_offset\":..}, \"updated_at\": \"...\"}. Remove `success` and the whole `meta` block (incl. meta.market_filter / meta.updated_at). Document the top-level `pagination` and `updated_at` fields. Replicate to de/es/pt-BR.", + "locale_scope": "all 4 locales (en/de/es/pt-BR identical)", + "unit": "odds-comparison" + }, + { + "title": "data is documented as a single nested event object; reality is a flat array of comparison entries", + "severity": "CRITICAL", + "category": "envelope", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "\"The response `data` is a single event object (not an array) containing nested markets and selections\" (line 209), with data.event_id/event_name/sport/league/start_time/is_live/books_available/markets[] (lines 211-222).", + "reality": "data is a []comparisonEntry — a flat array, each element {market_type, selection, line, books, book_holds}. No event wrapper, no nested markets[]/selections[]. Live confirmed: data is a JSON array.", + "evidence": "endpoints.go:184 (result := make([]comparisonEntry,...)), 256 (\"data\": result); live array shape observed", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite Response section: data is an array of {market_type, selection, line, books[], book_holds[]}. Delete the entire 'Event Object' table (event_id/event_name/sport/league/start_time/is_live/books_available/markets). The endpoint groups by market_type+selection+line across the selected books for the one event you queried.", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "JS and Python examples destructure non-existent fields (data.markets, market.hold, sel.books, sel.best_book, event.event_name) — they throw at runtime", + "severity": "CRITICAL", + "category": "example-breaks", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "JS iterates data.markets.forEach(...market.hold.best_available...market.selections.forEach...sel.books) (lines 57-65); Python does event=result['data']; event['event_name']; event['markets']; market['hold']['best_available']; sel['books'].items() (lines 79-90).", + "reality": "data is an array (no .markets, no .selections, no event_name); each entry has `books` as an ARRAY (not an object you can .items()/Object.entries over by book id), and there is no `hold` object, no `best_book`, no `best_odds`. JS Object.entries(sel.books) -> wrong; Python result['data']['event_name'] -> TypeError (list indices must be integers); event['markets'] -> TypeError.", + "evidence": "endpoints.go:169-175 (comparisonEntry has market_type/selection/line/books/book_holds only), 108-114 (books = []bookEntry array); doc lines 57-90", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite both code samples to iterate the array: for each entry, read entry.market_type, entry.selection, entry.line, and loop entry.books (an array of {sportsbook, odds_american, odds_decimal, timestamp}); use entry.book_holds (array of {sportsbook, hold}) for per-book hold. Update cURL examples to use event_id= (see separate finding).", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "All examples use the deprecated `event=` param, which hard-sunsets 2026-07-01 (5 days out) — copy-pasted code will start failing", + "severity": "HIGH", + "category": "param-noop", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Query Parameters table and every cURL/JS/Python example use `event` as the parameter name (lines 27, 41, 45, 52, 73, 289).", + "reality": "Canonical param is `event_id`; `event` is a legacy ALIAS that emits a Deprecation/Sunset response. Live: ?event=... returns header 'Deprecation: true', 'Sunset: Wed, 01 Jul 2026 00:00:00 GMT', 'Warning: 299 - \"filter alias(es) event deprecated; use event_id; sunset Wed, 01 Jul 2026 00:00:00 GMT\"'. After that date the alias may hard-reject.", + "evidence": "pkg/filters/parse.go:111 ([]string{\"event_id\"} canonical, []string{\"event\"} alias); live header: Sunset: Wed, 01 Jul 2026 00:00:00 GMT, Warning 299 event deprecated use event_id", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename the parameter to `event_id` in the Query Parameters table and in every cURL/JS/Python example. Optionally note `event` is a deprecated alias being sunset. (Prior audit L2 flagged this; still unfixed.)", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "selection.books documented as an object keyed by book id; reality is an array of book objects", + "severity": "HIGH", + "category": "field-name", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Selection Object: `books` is an 'object | Odds from each sportsbook (keyed by book ID)' (line 241); JSON sample shows books: {fanduel:{...}, draftkings:{...}} (lines 126-147).", + "reality": "books is a JSON ARRAY: []bookEntry, each {sportsbook, odds_american, odds_decimal, timestamp}, sorted by odds descending. The book id is a field INSIDE each element (`sportsbook`), not the key.", + "evidence": "endpoints.go:108-114 (bookEntry{Sportsbook json:\"sportsbook\"...}), 117 (books []bookEntry), 173; live: \"books\":[{\"sportsbook\":\"kalshi\",\"odds_american\":1150,...}]", + "current_status": "still-present", + "confidence": "high", + "fix": "Document `books` as an array of {sportsbook, odds_american, odds_decimal, timestamp}. Fix the JSON sample to use an array and add the `sportsbook` field to the Book Odds Object table.", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "hold is documented as a per-market object {best_available, by_book}; reality is per-entry book_holds array", + "severity": "HIGH", + "category": "field-name", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Market Object has hold/hold.best_available/hold.by_book (lines 112-120, 230-231); 'Understanding Hold' section describes best_available + by_book (258-270).", + "reality": "There is no `hold` object and no `best_available`/`by_book`. Each comparison entry instead carries `book_holds` (omitempty) — an array of {sportsbook, hold} computed by pairing each book's price with that book's opposing-side price. No cross-book 'best_available' hold is computed.", + "evidence": "endpoints.go:165-168 (bookHoldEntry{Sportsbook,Hold}), 174 (BookHolds []bookHoldEntry json:\"book_holds,omitempty\"), 226-231 (per-book hold via opposing side); live entry omitted book_holds because the queried selection had no opposing side", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace `hold.best_available`/`hold.by_book` docs with `book_holds`: array of {sportsbook, hold} where hold% is computed per book from its own two sides. Note book_holds is omitted when no opposing side is present. Rewrite the 'Understanding Hold' section accordingly (drop best_available).", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "Selection Object documents best_book/best_odds/worst_book/worst_odds/spread fields that do not exist", + "severity": "HIGH", + "category": "field-missing", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Selection Object table lists best_book, best_odds, worst_book, worst_odds, spread (lines 242-246); JSON sample includes them (148-152, 180-184); a whole 'Understanding the spread Field' section (272-280) and best/worst destructuring in examples.", + "reality": "comparisonEntry has only {market_type, selection, line, books, book_holds}. No best_book/best_odds/worst_book/worst_odds/spread are emitted. (Books are sorted by odds desc, so a client can derive best/worst from books[0]/books[last], but the API does not return these fields.)", + "evidence": "endpoints.go:169-175 (struct), 187-189 (sort desc only); live entry had none of these fields", + "current_status": "still-present", + "confidence": "high", + "fix": "Delete best_book/best_odds/worst_book/worst_odds/spread from the Selection table, the JSON sample, and the example code. Remove the 'Understanding the spread Field' section. Optionally note books[] is sorted best-first so clients can read books[0] for best price.", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "selection_type field is documented on each selection but is not returned", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Selection Object lists selection_type ('home, away, over, under') (line 239); JSON sample shows \"selection_type\":\"away\"/\"home\" (lines 124, 156).", + "reality": "comparisonEntry has no SelectionType field — selection_type is never serialized. The handler uses selection-type opposites only internally (oppositeType map) to compute book_holds; it is not surfaced in the response.", + "evidence": "endpoints.go:169-175 (no SelectionType in struct), 178-182 (oppositeType used only internally); live entry has no selection_type key", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove selection_type from the Selection Object table and the JSON sample, or add the field server-side if it is intended. (As shipped, it is absent.)", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "Missing event metadata fields (event_name, sport, league, start_time, is_live, books_available) documented but never returned", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "data.event_name, sport, league, start_time, is_live, books_available are all documented in the Event Object table and the JSON sample (lines 102-108, 215-221).", + "reality": "The response is a flat array with no event wrapper; none of these fields exist anywhere in the payload. The event identity is only what the caller passed as event_id.", + "evidence": "endpoints.go:184-262 (result array, no event metadata assembled); live response contained no event_name/sport/league/start_time/is_live/books_available", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove these fields from the docs (covered by the 'data is a flat array' rewrite). If event metadata is desired in the response, that's a server feature request, not a docs fix.", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "Pagination object is returned but entirely undocumented", + "severity": "LOW", + "category": "pagination", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "The page documents no pagination; no limit/offset params or pagination object are mentioned.", + "reality": "Response includes top-level pagination{limit, offset, count, has_more, next_offset}. Live: \"pagination\":{\"limit\":0,\"offset\":0,\"count\":1,\"has_more\":false,\"next_offset\":null}. The handler only populates Count (Pagination{Count: len(result)}); limit/offset default to 0 and has_more false, so it is effectively a count wrapper for the full result set.", + "evidence": "endpoints.go:257-259 (\"pagination\": Pagination{Count: len(result)}); live pagination object observed", + "current_status": "still-present", + "confidence": "high", + "fix": "Document the pagination object as {limit, offset, count, has_more, next_offset}; note that comparison returns all matching entries for the event (count = number of market+selection groups) and does not paginate (limit/offset always 0, has_more false).", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "Response Headers block shows X-RateLimit-* / X-Data-Delay that the endpoint does not emit on this path", + "severity": "LOW", + "category": "other", + "page": "api-reference/odds-comparison.mdx", + "docs_claim": "Response Headers shows X-RateLimit-Limit: 300, X-RateLimit-Remaining, X-RateLimit-Reset, X-Data-Delay: 0, X-Request-Id (lines 199-205).", + "reality": "Live backend response carried only X-Request-Id (plus Cache-Control private/max-age=0, ETag, Vary, X-Response-Cache). No X-RateLimit-* or X-Data-Delay were present on the comparison response in this read (rate-limit headers come from the rate-limit middleware/edge; X-Data-Delay only on delayed tiers). The handler sets Cache-Control 'public, s-maxage=1, max-age=1' though the edge returned 'private, max-age=0' — illustrative only.", + "evidence": "endpoints.go:253-254 (only Content-Type + Cache-Control set); live header dump: X-Request-Id, Cache-Control: private max-age=0, ETag, Vary, X-Response-Cache (no X-RateLimit-*/X-Data-Delay)", + "current_status": "still-present", + "confidence": "medium", + "fix": "Soften the Response Headers block to note rate-limit headers (X-RateLimit-*) are added at the edge for tiered keys and X-Data-Delay appears only on delayed (free) tiers; the example values are illustrative. Low priority / cosmetic.", + "locale_scope": "all 4 locales", + "unit": "odds-comparison" + }, + { + "title": "Response envelope is wrapped {success,data,meta} but reality is flat {data,updated_at}", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "Success (200) shows {\"success\": true, \"data\": {events, missing_events}, \"meta\": {requested, found, missing, max_batch, filters, updated_at}} (lines 122-192). 'Response Structure' section says counts live in meta (line 241).", + "reality": "Handler encodes only {\"data\": {\"events\": [...], \"missing_events\": [...]}, \"updated_at\": }. There is NO top-level \"success\" boolean and NO \"meta\" object. updated_at is top-level, not nested in meta.", + "evidence": "main.go:7543-7549 (json.NewEncoder encodes map with keys data{events,missing_events} and updated_at only)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the success/meta envelope with the flat shape: {\"data\":{\"events\":[...],\"missing_events\":[...]},\"updated_at\":\"...\"}. Remove the 'Meta Object' table; if counts are needed, derive them client-side from data.events.length and data.missing_events.length.", + "locale_scope": "en canonical; replicates to de, es, pt-BR (mirror en)", + "unit": "odds-batch" + }, + { + "title": "JS/Python examples destructure phantom meta.found/meta.requested — will throw / print undefined", + "severity": "HIGH", + "category": "example-breaks", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "JS example: const { data, meta } = await response.json(); console.log(`Fetched odds for ${meta.found} of ${meta.requested} events`) (lines 75-76). Python: meta = result['meta']; print(... meta['found']/meta['requested']) (lines 105-106). Dashboard Refresh JS also destructures meta (line 332).", + "reality": "Response has no meta key. In JS meta is undefined → meta.found throws TypeError. In Python result['meta'] raises KeyError. Copy-pasted customer code breaks immediately.", + "evidence": "main.go:7543-7549 (no meta key emitted)", + "current_status": "still-present", + "confidence": "high", + "fix": "Drop meta destructuring. Use data.events.length / data.missing_events.length for found/missing counts and event_ids.length for requested. e.g. const { data } = await response.json(); console.log(`Fetched ${data.events.length} of ${eventIds.length}`).", + "locale_scope": "en canonical; replicates to de, es, pt-BR", + "unit": "odds-batch" + }, + { + "title": "401 error example uses code 'unauthorized'; auth middleware emits missing_api_key / invalid_api_key", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "401 Unauthorized example shows {\"error\":{\"code\":\"unauthorized\",\"message\":\"Invalid or missing API key\",\"docs\":\"https://docs.sharpapi.io/en/authentication\"}} (lines 228-237).", + "reality": "Auth middleware returns code missing_api_key when no key is supplied and invalid_api_key when the key is bad. 'unauthorized' is reserved for admin/monitoring Bearer auth, never returned on this apiKey route.", + "evidence": "pkg/errcodes/errcodes.go:19,23,31 (InvalidAPIKey/MissingAPIKey/Unauthorized distinct constants); auth.go uses missing_api_key/invalid_api_key on apiKey routes", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the 401 example code to invalid_api_key (or show both missing_api_key for no-key and invalid_api_key for bad-key), matching the auth middleware.", + "locale_scope": "en canonical; replicates to de, es, pt-BR", + "unit": "odds-batch" + }, + { + "title": "Documented event-object field game_state does not exist in batch response", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "Event Object schema table lists `game_state | object | undefined | Live game state (only for live events with scores)` (line 278).", + "reality": "The eventData struct built by the handler has exactly: event_id, event_name, sport, league, start_time, is_live, odds. There is no game_state field on the batch event object (game state is a separate /gamestate endpoint / addon).", + "evidence": "main.go:7437-7445 (eventData struct json tags) and main.go:7481-7490 (event population — no game_state set)", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the game_state row from the Event Object table. If live game state is wanted, point users to /api/v1/gamestate (Game State add-on / Enterprise).", + "locale_scope": "en canonical; replicates to de, es, pt-BR", + "unit": "odds-batch" + }, + { + "title": "Odds object table omits emitted fields id, market_label, odds_probability (and conditional fields)", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "Odds Object (nested in event) table lists only sportsbook, market_type, selection, selection_type, odds_american, odds_decimal, line, timestamp (lines 283-292). Sample odds objects (lines 135-153) also omit these.", + "reality": "Each odds entry always also includes id, market_label, odds_probability, and conditionally market_segment, player_name, stat_category, external_event_id, event_uuid when populated. Request body also accepts a market_segment CSV filter not documented in the Request Body table.", + "evidence": "main.go:7495-7524 (entry map always sets id, market_label, odds_probability, timestamp; conditionally market_segment/player_name/stat_category/external_event_id/event_uuid); request struct main.go:7387 (MarketSegment json:market_segment)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add id, market_label, odds_probability to the Odds Object table and note the conditional player_name/stat_category/market_segment/external_event_id/event_uuid fields. Add market_segment (CSV) to the Request Body filter table.", + "locale_scope": "en canonical; replicates to de, es, pt-BR", + "unit": "odds-batch" + }, + { + "title": "Error response examples show a 'docs' field that the handler does not emit", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "All three error examples include a docs key inside error, e.g. \"docs\":\"https://docs.sharpapi.io/en/api-reference/odds-batch\" (lines 212,223,234).", + "reality": "oddsBatchHandler's own validation/method-not-allowed error bodies emit only {\"error\":{\"code\":..,\"message\":..}} with no docs field. (The auth-middleware 401 may add docs, but the batch-specific 400 bodies do not.)", + "evidence": "main.go:7400-7402 and main.go:7408-7413 and main.go:7371-7373 (error maps contain code+message only, no docs key)", + "current_status": "new", + "confidence": "medium", + "fix": "Remove the docs field from the 400 validation_error examples (or confirm/add it in the handler if intended). The 401 docs field is acceptable if auth middleware injects one.", + "locale_scope": "en canonical; replicates to de, es, pt-BR", + "unit": "odds-batch" + }, + { + "title": "Response Headers example shows X-Request-Id (req_batch_abc123) not emitted by handler", + "severity": "LOW", + "category": "other", + "page": "content/en/api-reference/odds-batch.mdx", + "docs_claim": "Response Headers block lists X-Request-Id: req_batch_abc123 (line 201) alongside X-RateLimit-* and X-Data-Delay.", + "reality": "The batch handler sets only Content-Type and Cache-Control headers; rate-limit/data-delay headers come from middleware. No X-Request-Id with a req_batch_* format is produced here; the illustrative value is invented.", + "evidence": "main.go:7541-7542 (only Content-Type + Cache-Control set in handler); rate-limit headers from rateLimitMiddleware (routes.go:69)", + "current_status": "still-present", + "confidence": "low", + "fix": "Verify against live response headers; if no X-Request-Id is emitted, drop that line, or replace with the real header set (X-RateLimit-Limit/Remaining/Reset, X-Data-Delay).", + "locale_scope": "en canonical; replicates to de, es, pt-BR", + "unit": "odds-batch" + }, + { + "title": "data.books[book] is an object {odds,...} not a ClosingOdd[]; JS/Python examples read the array off the wrong level", + "severity": "HIGH", + "category": "field-name", + "page": "api-reference/odds-closing.mdx", + "docs_claim": "Response JSON shows data.books.draftkings as a bare array of ClosingOdd; JS example does `for (const [book, odds] of Object.entries(data.books)) { ...odds.length... }` and Python does `for book, odds in snap['books'].items(): ...len(odds)`. Field table line 152: data.books = 'Map of book_id -> ClosingOdd[]'.", + "reality": "Each book maps to a BookClosing OBJECT {odds:[...], captured_at, seconds_before_kickoff, capture_trigger, is_final}. The ClosingOdd array is at value.odds. JS `odds.length` is undefined (prints 'undefined closing prices'); Python `len(odds)` returns the envelope key count (5), not the number of prices — both silently wrong, not what the customer wants for CLV.", + "evidence": "closing.go:152-158 (BookClosing struct) + closing.go:2503-2512 (snap.Books[field]=envelope); live: /odds/closing?event_id=milb_triple_a_durhambulls_norfolktides_2026-06-26_b3 -> data.books.saba keys = [odds, captured_at, seconds_before_kickoff, capture_trigger, is_final]", + "current_status": "still-present", + "confidence": "high", + "fix": "Show each book value as {\"odds\":[...], \"captured_at\":..., \"seconds_before_kickoff\":..., \"capture_trigger\":\"transition\", \"is_final\":true} in the JSON example. In JS use `book_data.odds.length`; in Python use `len(book_data['odds'])`. Change field-table line 152 type to 'Map of book_id -> BookClosing'.", + "locale_scope": "all 4 locales (en line 50-51/67/152; de/es/pt-BR identical at same lines)", + "unit": "odds-closing" + }, + { + "title": "BookClosing per-book envelope fields (capture_trigger, is_final, seconds_before_kickoff, per-book captured_at) are undocumented", + "severity": "HIGH", + "category": "field-missing", + "page": "api-reference/odds-closing.mdx", + "docs_claim": "Response Fields and ClosingOdd tables document only data.captured_at (top-level) and the per-odd fields; the per-book envelope is absent. The JSON example shows no envelope at all (book -> array).", + "reality": "Each book object carries capture_trigger (transition|kickoff|disappearance|evict|backfill — confidence ordering matters for CLV), is_final (bool), seconds_before_kickoff (int, can be negative), and a per-book captured_at. These are the load-bearing CLV-confidence/timing fields and are returned on every populated book.", + "evidence": "closing.go:152-158 (BookClosing json tags) + closing.go:67-80 (CaptureTrigger consts); live: data.books.saba -> capture_trigger='transition', is_final=true, seconds_before_kickoff=-6, captured_at='2026-06-26T22:35:05.661...Z'", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a 'BookClosing shape' table documenting odds (ClosingOdd[]), captured_at, seconds_before_kickoff, capture_trigger (enum + confidence note transition>kickoff>disappearance>evict), is_final.", + "locale_scope": "all 4 locales", + "unit": "odds-closing" + }, + { + "title": "ClosingOdd table omits the CLV devig fields actually returned (implied_probability, no_vig_probability, fair_close_decimal, closing_probability) plus market_id/selection_id/canonical_key/source_updated_at/team_side", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/odds-closing.mdx", + "docs_claim": "ClosingOdd table (lines 156-166) lists only sportsbook, market_type, selection, selection_type, line, odds_american, odds_decimal, player_name, stat_category.", + "reality": "Live ClosingOdd rows include implied_probability (ALWAYS present, no omitempty), no_vig_probability, fair_close_decimal, closing_probability, plus market_id, selection_id, canonical_key, source_updated_at, team_side (and market_segment, max_bet, odds_after_kickoff when applicable). The devig fields are the whole point of a CLV endpoint and are missing from the docs.", + "evidence": "closing.go:88-147 (ClosingOdd struct json tags); live: first saba odd keys = [sportsbook, market_type, market_id, selection, selection_type, selection_id, canonical_key, odds_american, odds_decimal, implied_probability, no_vig_probability, fair_close_decimal, closing_probability, source_updated_at, team_side]", + "current_status": "still-present", + "confidence": "high", + "fix": "Add rows for implied_probability (number, always present), no_vig_probability (number?, proportional-devig fair prob), fair_close_decimal (number?), closing_probability (number, power-devig; 0 when no pair), market_id, selection_id, canonical_key, source_updated_at, team_side, market_segment, max_bet, odds_after_kickoff.", + "locale_scope": "all 4 locales", + "unit": "odds-closing" + }, + { + "title": "odds_decimal documented as '3 decimal places' but is full float64 precision", + "severity": "LOW", + "category": "field-name", + "page": "api-reference/odds-closing.mdx", + "docs_claim": "Line 164: odds_decimal 'Decimal-format odds (3 decimal places).' Example JSON shows 1.606 / 2.30 / 1.633.", + "reality": "odds_decimal is a raw float64 (runner.AmericanToDecimal) serialized at full precision, e.g. 1.8333333333333335. Not rounded to 3 dp.", + "evidence": "closing.go:1502 decimal := runner.AmericanToDecimal(american); closing.go:98 DecimalOdds float64; live: odds_decimal=1.8333333333333335", + "current_status": "still-present", + "confidence": "high", + "fix": "Drop the '(3 decimal places)' claim; describe odds_decimal as full-precision decimal odds (round client-side for display).", + "locale_scope": "all 4 locales", + "unit": "odds-closing" + }, + { + "title": "Response JSON example shows book mapping directly to an array, omitting the {odds, captured_at, capture_trigger, is_final, seconds_before_kickoff} envelope", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "api-reference/odds-closing.mdx", + "docs_claim": "Lines 85-115: data.books.draftkings = [ {ClosingOdd}, {ClosingOdd} ], data.books.pinnacle = [ {ClosingOdd} ] (bare arrays).", + "reality": "Live structure is data.books. = { odds:[...ClosingOdd], captured_at, seconds_before_kickoff, capture_trigger, is_final }. The example as written would mislead anyone hand-coding against it (same root cause as the JS/Python finding).", + "evidence": "live: data.books.saba envelope keys = [odds, captured_at, seconds_before_kickoff, capture_trigger, is_final]; closing.go:2503-2512", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the JSON example so each book is the envelope object with an inner odds array, and include capture_trigger/is_final/seconds_before_kickoff so consumers see them.", + "locale_scope": "all 4 locales", + "unit": "odds-closing" + }, + { + "title": "Response envelope is wrong: docs show {success, meta, meta.summary, meta.filters}; API returns flat {data, pagination, updated_at}", + "severity": "CRITICAL", + "category": "envelope", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "Success (200) JSON shows top-level \"success\": true (L119), a \"meta\" object (L200) containing \"source\", \"last_update\", \"summary\" {count,avg_ev,max_ev,by_sportsbook,by_sport,by_market} (L203-220) and \"filters\" (L221-232). A Meta Summary Fields table (L354-363) documents summary.count/avg_ev/max_ev/by_* as real fields.", + "reality": "Handler writes exactly map{\"data\", \"pagination\", \"updated_at\"} with no success and no meta object. No summary/by_sportsbook/by_sport/by_market/avg_ev/max_ev exists anywhere. pagination is a Pagination struct with limit/offset/count/total/has_more/next_offset/next_cursor/warning.", + "evidence": "main.go:6984-6988 (json.NewEncoder.Encode of {data, pagination, updated_at}); live: /opportunities/ev?limit=1 -> {\"data\":[...],\"pagination\":{limit,offset,count,total,has_more,next_offset,next_cursor},\"updated_at\":...} with no success/meta keys", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the success/meta envelope with the flat shape {data:[...], pagination:{limit,offset,count,total,has_more,next_offset,next_cursor}, updated_at}. Delete the Meta Summary Fields table (L354-363) and the meta block in the JSON sample. Apply to all 4 locales.", + "locale_scope": "all 4 locales (de/es/pt-BR each have 1 success-envelope + 2 meta.summary/count occurrences)", + "unit": "opp-ev" + }, + { + "title": "JS/Python examples destructure meta/meta.count/meta.summary that don't exist — copy-pasted code throws/prints undefined", + "severity": "HIGH", + "category": "example-breaks", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "JavaScript example: const { data, meta } = await response.json(); console.log(`Found ${meta.count} ...`); console.log(`Average EV: ${meta.summary.avg_ev}%`); (L89-91).", + "reality": "Response has no meta key, so meta is undefined; meta.count -> undefined and meta.summary.avg_ev -> TypeError: Cannot read properties of undefined. The count lives at pagination.count (the page size) / pagination.total (the full match count). avg_ev is not provided at all.", + "evidence": "main.go:6984-6988 (no meta in response); live: response top-level keys are data/pagination/updated_at only", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the JS example to read pagination from the flat envelope, e.g. const { data, pagination } = await res.json(); console.log(`Found ${pagination.total} +EV opportunities`); and drop the avg_ev line (no server-side summary). Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "EV response field names are wrong: game_id/market/game/confidence_score should be event_id/market_type/event_name/confidence", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "Both JSON samples and the Response Fields table use game_id (L122,159,288), market (L133,300), game (L136,303), and confidence_score (L143,179,310). Python example also prints opp['ev_percentage'] (OK) but the table headers are game_id/market/game/confidence_score.", + "reality": "transformEV emits event_id (not game_id), market_type (not market), event_name (not game), and confidence (not confidence_score). No game_id/game/market/confidence_score keys are emitted.", + "evidence": "transforms.go:1191 (event_id), 1192 (event_name), 1197 (market_type), 1222 (confidence); live: data[0] keys event_id/event_name/market_type/confidence present, no game_id/game/market/confidence_score", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename in JSON samples and the Response Fields table: game_id->event_id, market->market_type, game->event_name, confidence_score->confidence. Apply to all 4 locales (de/es/pt-BR have game_id x3 and confidence_score x9 each).", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "403 error example uses fictional code feature_required; real gate returns tier_restricted with tier/required_tier", + "severity": "HIGH", + "category": "errcode", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "403 example (L260-268): {\"error\":{\"code\":\"feature_required\",\"message\":\"The 'ev' feature is required. Upgrade to Pro or higher.\",\"docs\":\"https://docs.sharpapi.io/en/pricing\"}}", + "reality": "evGate=tierGateMiddleware(\"ev\") returns 403 {\"error\":{\"code\":\"tier_restricted\",\"message\":\"This endpoint requires Pro tier or higher\",\"docs\":\"https://sharpapi.io/pricing\",\"tier\":\"\",\"required_tier\":\"pro\"}}. The code feature_required does not exist anywhere in the Go codebase (grep: 0 hits).", + "evidence": "routes.go:49 (evGate := tierGateMiddleware(\"ev\")); tiers.go:333-340 (code errcodes.TierRestricted, message, docs https://sharpapi.io/pricing, tier, required_tier); pkg/errcodes/errcodes.go:29 (TierRestricted=\"tier_restricted\"); grep feature_required in *.go -> 0 hits", + "current_status": "new", + "confidence": "high", + "fix": "Change the 403 example code to \"tier_restricted\", message to \"This endpoint requires Pro tier or higher\", docs to https://sharpapi.io/pricing, and add \"tier\" and \"required_tier\":\"pro\" fields. Apply to all 4 locales (feature_required x1 each).", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "401 error example uses code \"unauthorized\"; real 401 codes are missing_api_key / invalid_api_key", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "401 example (L250-256): {\"error\":{\"code\":\"unauthorized\",\"message\":\"Invalid or missing API key\",\"docs\":\"https://docs.sharpapi.io/en/authentication\"}}", + "reality": "apiKey auth returns missing_api_key (no key) or invalid_api_key (bad key). The code \"unauthorized\" is reserved for admin/monitoring Bearer auth, not customer apiKey routes. Live confirms invalid_api_key for a bad key.", + "evidence": "pkg/errcodes/errcodes.go:19 (InvalidAPIKey=\"invalid_api_key\"), :23 (MissingAPIKey=\"missing_api_key\"), :31 (Unauthorized=\"unauthorized\"); live: bad key -> {\"error\":{\"code\":\"invalid_api_key\",\"message\":\"Invalid API key\"}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the 401 example code to invalid_api_key (or document both missing_api_key/invalid_api_key). Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "devig_method value is UPPERCASE (POWER), docs show lowercase \"power\"", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "JSON samples show \"devig_method\": \"power\" (L130,166); Response Fields says \"De-vig method used (e.g., power)\" (L297); the math walkthrough refers to the Power method.", + "reality": "devig_method is emitted verbatim from raw and is UPPERCASE in production, e.g. \"POWER\". Clients doing case-sensitive equality on \"power\" will mismatch.", + "evidence": "transforms.go:1217 (\"devig_method\": getStr(raw, \"devig_method\")); live: data[0].devig_method = \"POWER\"", + "current_status": "new", + "confidence": "high", + "fix": "Change example values and field-table example to \"POWER\" (note the value is uppercase). Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "pagination block in JSON sample omits real keys count, total, next_cursor", + "severity": "MEDIUM", + "category": "pagination", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "pagination sample (L194-199) shows only {limit, offset, has_more, next_offset:null}.", + "reality": "Pagination object also carries count (page size), total (full match count), and next_cursor (set when has_more on default EV-desc sort). The endpoint supports cursor pagination (cursor= param + next_cursor) which is undocumented on this page.", + "evidence": "main.go:6986 (Pagination{Limit,Offset,Count,Total,HasMore,NextOffset,NextCursor,Warning}); main.go:6907-6932 (cursor pagination); live: pagination = {limit:1,offset:0,count:1,total:136,has_more:true,next_offset:1,next_cursor:\"eyJ2...\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Add count, total, and next_cursor to the pagination sample and document the cursor= param / next_cursor stable-pagination path. Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "Response Fields table documents non-existent deprecated aliases ev_percent and devig_book", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "ev_percentage \"Deprecated alias: ev_percent\" (L291); sharp_book \"Deprecated alias: devig_book\" (L298).", + "reality": "transformEV emits neither ev_percent nor devig_book in the response; only ev_percentage and sharp_book are emitted. (devig_book exists only as a query param, not a response field; ev_percent is not emitted at all.) Live output has no ev_percent/devig_book keys.", + "evidence": "transforms.go:1206 (ev_percentage), 1218 (sharp_book) — no ev_percent or devig_book in out map; live: data[0] has no ev_percent/devig_book keys", + "current_status": "still-present", + "confidence": "medium", + "fix": "Remove the 'Deprecated alias' notes for ev_percent and devig_book (no such response keys exist), or clarify devig_book is a query-param name only. Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "Several real response fields are undocumented (display_selection, sportsbooks[], odds_probability, partner_fair_probability, is_alternate_line, selection_id, market_id, cross_ref_count, deep_link)", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "Response Fields table lists id, game_id, external_event_id, selection_id, ev_percentage, odds_*, no_vig_odds, fair_probability, market_width, devig_method, sharp_book, selection, market, line, sportsbook, game, sport, league, home_team, away_team, start_time, is_live, confidence_score, kelly_percent, book_count, arb_available, arb_profit, is_player_prop, player_name, player_id, stat_category, possibly_stale, oldest_odds_age_seconds, warnings, detected_at + nested refs.", + "reality": "Live + transformEV also emit: display_selection, sportsbooks (array of book ids), odds_probability, partner_fair_probability, is_alternate_line, cross_ref_count, cross_ref_dispersion, market_id, deep_link, est_correction_s, single_sharp_period (and conditionally team_side, market_segment, is_suspended/suspended_since). selection_id IS documented (good).", + "evidence": "transforms.go:1199 (display_selection), 1202 (sportsbooks), 1205 (odds_probability), 1215 (partner_fair_probability), 1228 (is_alternate_line), 1233 (market_id), 1241 (deep_link), 1244-1245 (cross_ref_dispersion/count); live keys include display_selection,sportsbooks,odds_probability,partner_fair_probability,is_alternate_line,cross_ref_count,est_correction_s,single_sharp_period,market_id,deep_link", + "current_status": "still-present", + "confidence": "high", + "fix": "Add the missing fields to the Response Fields table (at minimum display_selection, sportsbooks[], odds_probability, partner_fair_probability, is_alternate_line, market_id, deep_link). Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "id example format ev_dk_nba_..._ml_PHO is not the real id shape (short hex hash)", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "JSON samples use \"id\": \"ev_dk_nba_33483153_ml_PHO\" and \"ev_fd_nba_33483153_ml_PHO\" (L122,158); the nested-ref sample uses \"id\": \"ev_pinnacle_mlb_yankees_redsox_moneyline_NYY\" (L335).", + "reality": "id is a deterministic short hash (hash_id / betting_tool_id / generateDeterministicId), e.g. live id = \"dc7bfb33d447ef27\". The descriptive ev__<...> token format is not what the API returns.", + "evidence": "transforms.go:1177-1183 (hashID from hash_id/betting_tool_id/generateDeterministicId), 1190; live: data[0].id = \"dc7bfb33d447ef27\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the illustrative id values with a realistic short hex hash (e.g. \"dc7bfb33d447ef27\") and describe id as an opaque deterministic hash. Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "sharp_book/devig source described as always Pinnacle; production also uses other sharp anchors (e.g. circa)", + "severity": "LOW", + "category": "conceptual", + "page": "content/en/api-reference/opportunities-ev.mdx", + "docs_claim": "How SharpAPI Calculates EV: \"SharpAPI uses Pinnacle ... as the source of truth\" (L381); fair_probability = \"No-vig probability derived from the sharp book (Pinnacle)\" (L376); JSON samples show \"sharp_book\":\"pinnacle\".", + "reality": "Pinnacle is the DEFAULT devig anchor but the engine selects from a set of sharp books and stamps the actual anchor; live row used sharp_book=circa. devig_book param can override. The framing 'uses Pinnacle' is the common case but not universal.", + "evidence": "transforms.go:1148 (sharpBook := getStr(raw, \"devig_book\")); live: data[0].sharp_book = \"circa\" (sportsbook sx_bet, sport soccer)", + "current_status": "new", + "confidence": "medium", + "fix": "Soften to 'Pinnacle is the default sharp anchor; other sharp books (e.g. Circa, Bookmaker) may be used when Pinnacle hasn't priced the market — sharp_book reports the actual anchor used.' Apply to all 4 locales.", + "locale_scope": "all 4 locales", + "unit": "opp-ev" + }, + { + "title": "Response envelope wrong: documents {success, meta} wrapper; API returns flat {data, pagination, updated_at}", + "severity": "CRITICAL", + "category": "envelope", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "Success (200) shows `{\"success\": true, \"data\": [...], \"pagination\": {...}, \"meta\": {\"source\", \"summary\":{count,avg_profit,max_profit,by_market,by_sport}, \"filters\":{...}, \"books_analyzed\":8}}` (lines 97-176). JS example destructures `const { data, meta } = await response.json()` (line 64).", + "reality": "Handler returns ONLY `{data, pagination, updated_at}` — no `success` key, no `meta` object. The entire meta.source/meta.summary/meta.filters/meta.books_analyzed block is fictional.", + "evidence": "main.go:6984-6988 (json.NewEncoder.Encode of {data, pagination, updated_at}); live: /api/v1/opportunities/arbitrage?limit=2 -> top-level keys = data, pagination, updated_at (no success, no meta)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the JSON sample: drop `\"success\": true` and the whole `\"meta\": {...}` block; add `\"updated_at\": \"\"` as a sibling of `data`/`pagination`. Fix the JS example to `const { data } = ...` (meta is undefined). Pagination summary stats (count/avg_profit/by_market) are NOT returned — remove or move to a 'computed client-side' note.", + "locale_scope": "all 4 locales (en/de/es/pt-BR mirror this structure)", + "unit": "opp-arb" + }, + { + "title": "format=csv is a no-op: param + entire CSV Format section are fictional", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "`format` query param: `json` or `csv`, 'CSV downloads as arbitrage_YYYY-MM-DD.csv' (line 29); profit-cap callout + CSV Format section (lines 317-323) list columns event,sport,market,line,profit%,implied_total,book1,selection1,odds1,stake1%,book2,selection2,odds2,stake2%,is_live,is_alternate_line,possibly_stale,warnings,detected_at.", + "reality": "There is NO CSV handling in the opportunities handler. `format=csv` is ignored; response is Content-Type: application/json with the normal JSON body. No `format`/`csv`/Content-Disposition handling exists anywhere in the Go server for this route.", + "evidence": "grep of main.go + pkg/filters/*.go: no `format`/`text/csv`/`Content-Disposition`/`attachment` handling for opportunities; live: GET ...?format=csv -> response header `Content-Type: application/json`, body `{\"data\":[{...}]}` (JSON, not CSV)", + "current_status": "new", + "confidence": "high", + "fix": "Remove the `format` query-parameter row (line 29), the CSV filename mention, and the entire 'CSV Format' section (lines 317-323). If CSV is a desired roadmap item, mark it explicitly 'not yet available'.", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "Leg field documented as implied_probability; API emits odds_probability", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "Leg Object table line 259: `implied_probability | number | Implied probability (0.0 to 1.0)`; JSON sample lines 128,141 use `\"implied_probability\"`.", + "reality": "Each leg emits `odds_probability` (0.0-1.0). There is no `implied_probability` key on the leg. Customer code reading `leg.implied_probability` gets undefined/KeyError.", + "evidence": "transforms.go:968 (\"odds_probability\": implProb); live leg keys -> odds_probability:0.2222 (no implied_probability)", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename the leg field and both JSON-sample occurrences from `implied_probability` to `odds_probability`.", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "Leg `timestamp` field documented but does not exist in response", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "Leg Object table line 261: `timestamp | string|null | When these odds were captured (ISO 8601)`; JSON sample lines 130,142 show `\"timestamp\": \"2026-02-08T14:22:05.000Z\"`.", + "reality": "transformArbitrage builds the leg map without any `timestamp` key — it is dropped. Live leg objects have no `timestamp` field.", + "evidence": "transforms.go:962-975 (leg map has sportsbook,selection,line,odds_american,odds_decimal,odds_probability,stake_percent,selection_id,market_id,external_event_id,odds_id,deep_link — no timestamp); live leg keys contain no timestamp", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `timestamp` row from the Leg Object table and the `\"timestamp\"` lines from the JSON sample.", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "Leg fields odds_id, deep_link, and per-leg line are emitted but undocumented", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "Leg Object table (lines 253-270) documents sportsbook/selection/odds_american/odds_decimal/implied_probability/stake_percent/timestamp/external_event_id/selection_id/market_id + nested refs. It does NOT list `odds_id`, `deep_link`, or a per-leg `line`.", + "reality": "Each leg additionally carries `odds_id` (internal id feeding /api/v1/deeplink/), `deep_link` (string|null, ready-to-use sportsbook deep link), and `line` (number|null, the leg's line). `deep_link` is directly customer-actionable (place-the-bet URL).", + "evidence": "transforms.go:965 (\"line\"), 973 (\"odds_id\"), 974 (\"deep_link\"); live leg -> {line:4.5, odds_id:\"227863731294753\", deep_link:\"https://api.sharpapi.io/api/v1/deeplink/227863731294753?state=pa\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Add `odds_id` (string|null), `deep_link` (string|null), and `line` (number|null) rows to the Leg Object table, and include them in the JSON sample. Note deep_link can be null when the book provides no raw URL (live shows one leg with deep_link:null).", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "401 example uses code 'unauthorized' and wrong docs URL; real code is invalid_api_key / missing_api_key", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "401 body (lines 192-200): `{\"error\":{\"code\":\"unauthorized\",\"message\":\"Invalid or missing API key\",\"docs\":\"https://docs.sharpapi.io/en/authentication\"}}`.", + "reality": "A bad key returns `code:\"invalid_api_key\"`; a missing key returns `code:\"missing_api_key\"`. `unauthorized` is reserved for admin/monitoring Bearer auth, never this route. Live docs URL is `https://sharpapi.io/docs/authentication`.", + "evidence": "pkg/errcodes/errcodes.go:19 (InvalidAPIKey=\"invalid_api_key\"), :23 (MissingAPIKey=\"missing_api_key\"); live: bad key -> {\"error\":{\"code\":\"invalid_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"Invalid API key\"}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Change 401 example `code` to `invalid_api_key` (and/or note `missing_api_key` for no key); fix `docs` URL to `https://sharpapi.io/docs/authentication`.", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "403 tier error missing tier/required_tier fields and uses wrong docs URL", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "403 body (lines 204-210): `{\"error\":{\"code\":\"tier_restricted\",\"message\":\"Arbitrage detection requires Hobby tier or higher\",\"docs\":\"https://docs.sharpapi.io/en/pricing\"}}`.", + "reality": "tierGateMiddleware emits two extra fields the doc omits: `tier` (caller's tier) and `required_tier`, and the docs URL is `https://sharpapi.io/pricing`. Message wording is `This endpoint requires Hobby tier or higher`.", + "evidence": "tiers.go:333-340 (code, message 'This endpoint requires %s tier or higher', docs 'https://sharpapi.io/pricing', tier, required_tier)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add `\"tier\": \"free\"` and `\"required_tier\": \"hobby\"` to the 403 example; change docs URL to `https://sharpapi.io/pricing`; align message to the actual 'This endpoint requires Hobby tier or higher'.", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "Pagination block omits count and total; example shows only limit/offset/has_more/next_offset", + "severity": "LOW", + "category": "pagination", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "JSON sample pagination block (lines 151-156): `{\"limit\":50,\"offset\":0,\"has_more\":false,\"next_offset\":null}` — and the real count/total are misplaced into the fictional `meta.summary.count`.", + "reality": "Pagination struct also serializes `count` (this-page len) and `total` (full match count). Live pagination = {limit:2, offset:0, count:2, total:89, has_more:true, next_offset:2}.", + "evidence": "pkg/httputil/httputil.go Pagination: Count `json:\"count\"`, Total `json:\"total,omitempty\"`; live pagination -> count:2,total:89", + "current_status": "still-present", + "confidence": "high", + "fix": "Add `count` and `total` to the pagination block in the JSON sample (and remove the duplicate `count` from the fictional meta.summary that this finding overlaps with the envelope fix).", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "Top-level fields estimated_net_profit_percent, league_label, market_label emitted but undocumented", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "Response Fields table (lines 227-249) does not list `estimated_net_profit_percent`, `league_label`, or `market_label`.", + "reality": "Each opportunity emits `estimated_net_profit_percent` (profit_percent minus a 0.2% estimated cost), `league_label` (display label, e.g. 'MLB'), and `market_label` (display label, e.g. 'Total Runs').", + "evidence": "transforms.go:1058 (estimated_net_profit_percent = round2(percentage - estimatedCostPercent); estimatedCostPercent=0.2 at :930), :1041 (league_label), :1043 (market_label); live opp -> estimated_net_profit_percent:9.72, league_label:\"MLB\", market_label:\"1st 3 Innings Total Runs\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Add rows for `estimated_net_profit_percent` (number), `league_label` (string), `market_label` (string) to the Response Fields table and include them in the JSON sample.", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "warnings enum lists POTENTIALLY_STALE_ODDS but live string is LIVE_STALE_ODDS", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/opportunities-arbitrage.mdx", + "docs_claim": "warnings possible values (line 242): LIVE_GAME, LIVE_HIGH_PROFIT_SUSPICIOUS, LOW_IMPLIED_TOTAL, POTENTIALLY_STALE_ODDS, VERY_STALE_ODDS.", + "reality": "The transform's possibly_stale check looks for POTENTIALLY_STALE_ODDS / LIVE_STALE_ODDS / STALE_PREMATCH_ODDS, and live responses emit `LIVE_STALE_ODDS`. The documented `POTENTIALLY_STALE_ODDS` may be a stale/aspirational name; the live-observed staleness warning is `LIVE_STALE_ODDS`.", + "evidence": "transforms.go:1012 (checks \"POTENTIALLY_STALE_ODDS\" || \"LIVE_STALE_ODDS\" || \"STALE_PREMATCH_ODDS\"); live warnings array -> [\"LIVE_HIGH_PROFIT_SUSPICIOUS\",\"LIVE_GAME\",\"LIVE_STALE_ODDS\"]", + "current_status": "still-present", + "confidence": "medium", + "fix": "Audit the actual warning-string set produced by arbengine and align the enum (add LIVE_STALE_ODDS / STALE_PREMATCH_ODDS; confirm whether POTENTIALLY_STALE_ODDS is ever emitted).", + "locale_scope": "all 4 locales", + "unit": "opp-arb" + }, + { + "title": "Middles-summary documents a wholly fictional response body", + "severity": "CRITICAL", + "category": "field-name", + "page": "content/en/api-reference/opportunities-middles-summary.mdx", + "docs_claim": "Summary returns data:{total, positive_ev_count, guaranteed_profit_count, player_prop_count, by_sport, by_market, by_sportsbook, avg_middle_size, avg_probability, avg_ev, best_ev:{ev,sport,market}, has_key_numbers_count} (lines 86-122, fields table 168-186, JS/Python examples 43-78 read all of these).", + "reality": "API returns data:{total, by_sport, by_league} (no other top-level keys). by_sport and by_league are maps of bucket -> {count, avg_quality_score, avg_expected_value}. There is NO by_market, by_sportsbook, positive_ev_count, guaranteed_profit_count, player_prop_count, avg_middle_size, avg_probability, avg_ev, best_ev, or has_key_numbers_count.", + "evidence": "main.go:7097-7186 (middlesSummaryImpl: bucket struct = {count,sumQuality,sumEV}; finalize emits count/avg_quality_score/avg_expected_value; data has only total/by_sport/by_league). live: /opportunities/middles/summary -> data keys = ['by_league','by_sport','total']; by_sport.baseball = {avg_expected_value:10.52, avg_quality_score:4.81, count:222}", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the entire summary response section + fields table + JS/Python examples to match {data:{total, by_sport:{:{count,avg_quality_score,avg_expected_value}}, by_league:{...}}, updated_at}. Remove all the invented aggregate fields and the by_market/by_sportsbook/best_ev breakdowns.", + "locale_scope": "EN canonical; replicates to de/es/pt-BR mirrors", + "unit": "opp-middles" + }, + { + "title": "Both middles pages wrap response in {success, data, meta} — real envelope is {data, pagination, updated_at} / {data, updated_at}", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/opportunities-middles.mdx, content/en/api-reference/opportunities-middles-summary.mdx", + "docs_claim": "List shows {success:true, data:[...], meta:{count,total,source,pagination,summary,filters,updated_at}} (middles.mdx:90-160). Summary shows {success:true, data:{...}, meta:{source,filters,updated_at}} (summary.mdx:87-131). Both JS examples destructure const {data, meta} (middles.mdx:49, summary.mdx:41).", + "reality": "List endpoint returns {data, pagination, updated_at} — no success field, no meta object; pagination is top-level {limit,offset,count,total,has_more,next_offset}. Summary returns {data, updated_at} — no success, no meta. Detail returns {data, updated_at}. Code never reads meta.* (would be undefined in the JS examples).", + "evidence": "live: /opportunities/middles?limit=2 -> top keys ['data','pagination','updated_at'], has success=False, has meta=False, pagination={limit,offset,count,total,has_more,next_offset}. live: /summary -> top keys ['data','updated_at']. main.go:7170-7185 (summary encodes {data,updated_at}); main.go:7086-7092 (detail encodes {data,updated_at})", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the {success/meta} envelope with the real flat-paginated shape {data, pagination, updated_at} on the list page and {data, updated_at} on the summary page. Change JS examples to read data + pagination (not meta). Move count/total/has_more/next_offset into a top-level pagination object.", + "locale_scope": "EN canonical; replicates to all 4 locales; part of the systemic envelope error across ~16 pages", + "unit": "opp-middles" + }, + { + "title": "Side objects documented with nested odds{} — API emits flat odds_american/odds_decimal/odds_probability/fair_probability", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/opportunities-middles.mdx", + "docs_claim": "side1/side2 contain odds:{american,decimal,probability,fair_probability} (JSON sample lines 107,115,265; Side Object table rows 241-244 list odds.american/odds.decimal/odds.probability/odds.fair_probability). JS example reads middle.middle_probability only, but the contract table promises a nested odds object.", + "reality": "Each side is FLAT: odds_american (int), odds_decimal, odds_probability, fair_probability — there is no nested odds{} object. Customer code reading side1.odds.american gets undefined.", + "evidence": "transforms.go:1404-1417 (side1Map keys: odds_american, odds_decimal, odds_probability, fair_probability — flat). live: /opportunities/middles?limit=2 -> side1 = {book, fair_probability:0.5464, line, odds_age_seconds, odds_american:-110, odds_decimal:1.909, odds_probability:0.5238, selection, stake_percent} (no 'odds' key)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the nested odds:{american,decimal,probability,fair_probability} in the side1/side2 JSON samples (lines 107,115,265) with flat odds_american/odds_decimal/odds_probability/fair_probability, and update Side Object table rows 241-244 accordingly. The May-2026 nested-refs example block (line 265) repeats the wrong nested odds and should also be flattened.", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "min_size query parameter is a complete no-op (silently ignored)", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/api-reference/opportunities-middles.mdx", + "docs_claim": "min_size param (default 0.5) 'Minimum middle size in points' (line 27); cURL/JS/Python examples all pass min_size=1 (lines 39,46,69); Best Practices and Callouts repeatedly tell users to set min_size>=1 (lines 386,401).", + "reality": "Go never parses min_size. The only middles-specific numeric filter is min_profit, which is applied against roi_percentage (not middle size in points). Passing min_size has zero effect and is not even rejected as invalid_filter.", + "evidence": "main.go:6692-6703 (opType=='middles' branch applies ONLY res.Filters.MinProfit vs roi_percentage); grep min_size across *.go (non-test) returns no parser. live: /opportunities/middles?min_size=99&limit=2 -> no error, pagination.total=398 (unchanged full set)", + "current_status": "still-present", + "confidence": "high", + "fix": "Either document min_profit (filters by roi_percentage %) instead of min_size, or note that min_size is not yet supported. Update the param table, all three example requests, the Callout (line 385-387), and Best Practices (line 401) to stop instructing customers to rely on min_size. Note default '0.5' is also wrong/irrelevant.", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "401 error code documented as 'unauthorized' — real codes are missing_api_key / invalid_api_key", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-middles.mdx, content/en/api-reference/opportunities-middles-summary.mdx", + "docs_claim": "401 body code:'unauthorized', message 'Invalid or missing API key', docs 'https://docs.sharpapi.io/en/authentication' (middles.mdx:175-184; summary.mdx:146-155).", + "reality": "Missing key -> 401 {code:'missing_api_key', message:'API key required...', docs:'https://sharpapi.io/docs/authentication'}. Bad key -> 401 {code:'invalid_api_key', message:'Invalid API key'}. 'unauthorized' is reserved for admin/monitoring Bearer auth, never returned here.", + "evidence": "live: no key -> {code:'missing_api_key', docs:'https://sharpapi.io/docs/authentication'}; bad key -> {code:'invalid_api_key'}. errcodes: missing_api_key/invalid_api_key are the apiKey 401 codes", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the 401 example with the two real shapes (missing_api_key when no key, invalid_api_key when bad), correct the docs URL to https://sharpapi.io/docs/authentication, and drop 'unauthorized'.", + "locale_scope": "EN canonical; replicates to all 4 locales; systemic across opportunity pages", + "unit": "opp-middles" + }, + { + "title": "403 tier_restricted body omits tier/required_tier fields and has wrong message + docs URL", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-middles.mdx, content/en/api-reference/opportunities-middles-summary.mdx", + "docs_claim": "403 body = {code:'tier_restricted', message:'Middles detection requires Pro tier or higher', docs:'https://docs.sharpapi.io/en/pricing'} (middles.mdx:186-195; summary.mdx:157-166).", + "reality": "Real 403 = {code:'tier_restricted', message:'This endpoint requires Pro tier or higher', docs:'https://sharpapi.io/pricing', tier:'', required_tier:'pro'}. Docs omit the tier and required_tier fields, use a different message, and a different docs URL.", + "evidence": "tiers.go:333-341 (error map: code, message=fmt 'This endpoint requires %s tier or higher', docs='https://sharpapi.io/pricing', tier, required_tier). Route gate = tierGateMiddleware('middles') at routes.go:51,99", + "current_status": "still-present", + "confidence": "high", + "fix": "Update the 403 sample to include tier and required_tier:'pro', use message 'This endpoint requires Pro tier or higher', and docs URL https://sharpapi.io/pricing.", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "Middles detail returns 404 (not 400) for malformed ID, and envelope is {data, updated_at} not raw object", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-middles.mdx", + "docs_claim": "Detail endpoint 'Returns a single middle object (same schema as the list items). Returns 404 if not found, 400 if the ID format is invalid.' (line 323).", + "reality": "A malformed (non-16-hex) ID returns 404 NotFound, not 400 — isHexID16 failure goes straight to 404. Also the body is wrapped: {data:{...middle...}, updated_at}, not a bare middle object as 'a single middle object' implies.", + "evidence": "main.go:7067-7070 (middleByIDImpl: if !isHexID16(id) -> writeJSONError 404 NotFound); main.go:7086-7091 (encodes {data: transformMiddle(m), updated_at}). live: /opportunities/middles/ -> top keys ['data','updated_at']", + "current_status": "still-present", + "confidence": "high", + "fix": "Change 'returns 400 if the ID format is invalid' to 404 (the handler never emits 400 here), and clarify the body is {data, updated_at} wrapping the middle object.", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "List meta block documents source/summary/filters subfields that don't exist", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-middles.mdx", + "docs_claim": "meta object includes count, total, source:'cache', pagination, summary:{count}, filters:{sport,league,sportsbook,market,min_size}, updated_at (lines 140-159). Summary page meta has source:'cache' and filters (summary.mdx:123-130). Callout claims summary 'cached for 10 seconds' (summary.mdx:211-213).", + "reality": "No meta object is returned at all (see envelope finding). Even conceptually there is no 'source', no 'summary' sub-object, no filters echo, and no min_size filter. The Cache-Control is s-maxage=1/max-age=1 (1s), contradicting the documented 10s cache.", + "evidence": "main.go:7083,7170 (Cache-Control 'public, s-maxage=1, max-age=1'); main.go:7170-7185 summary encodes only {data,updated_at} — no source/filters. live confirmed no meta key on either endpoint", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the meta.source/meta.summary/meta.filters documentation; fix the 'cached for 10 seconds' claim (actual s-maxage=1). Covered partly by the envelope finding but the cache-duration and filters-echo specifics need separate correction.", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "Several list response-field-table rows / sample fields don't match output ordering and omit real fields", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-middles.mdx", + "docs_claim": "Response Fields table (lines 199-232) lists quality_score and market_overround but the JSON sample (lines 90-138) omits them; sample includes them nowhere. Side Object table (236-246) documents deep_link as a side field.", + "reality": "API emits quality_score and market_overround on every item (good — table is right, sample is incomplete). worst_case_pnl, league_label, market_label, is_team_total, team_name, alt_side1, alt_side2 are emitted but undocumented. Per side, deep_link is NOT emitted by transformMiddle (only selection_id/market_id/external_event_id are), so documenting side.deep_link (line 247) is misleading.", + "evidence": "transforms.go:1404-1417 side maps have no deep_link key (only selection_id/market_id/external_event_id); transforms.go:1430-1471 root has worst_case_pnl, league_label, market_label, is_team_total, team_name, alt_side1, alt_side2. live: side1 has no deep_link; item has worst_case_pnl:4.31, league_label, market_label, is_team_total, alt_side1/alt_side2", + "current_status": "still-present", + "confidence": "medium", + "fix": "Add quality_score + market_overround to the JSON sample; remove side.deep_link (line 247) or mark it not-yet-emitted; optionally document the emitted-but-undocumented additive fields (worst_case_pnl, league_label, market_label, is_team_total, team_name, alt_side1, alt_side2).", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "Summary param table claims comma-separated multi-value sport/league but examples/handler treat them as filters consistently — minor, plus market/sportsbook filters undocumented on list", + "severity": "LOW", + "category": "param-noop", + "page": "content/en/api-reference/opportunities-middles-summary.mdx", + "docs_claim": "Summary supports only sport and league single values (lines 21-24).", + "reality": "Summary handler runs full filters.ParseQuery and supports comma-separated sport/league (multi-value) and rejects unknown filters with invalid_filter; it does NOT support market/sportsbook filtering (those are ignored in the by_sport/by_league aggregation). The doc's single-value framing understates sport/league CSV support but is otherwise harmless.", + "evidence": "main.go:7100-7104 (filters.ParseQuery + HasInvalid -> writeInvalidFilterError); main.go:7107-7108 sportFilter/leagueFilter are sets (CSV). Only sport/league applied in the loop (main.go:7122-7128)", + "current_status": "new", + "confidence": "low", + "fix": "Optionally note sport/league accept comma-separated lists; clarify that market/sportsbook filters are not honored by the summary endpoint. Low priority.", + "locale_scope": "EN canonical; replicates to all 4 locales", + "unit": "opp-middles" + }, + { + "title": "Response envelope is wholesale wrong: docs show {success, data, pagination, meta} but API returns {data, pagination, updated_at} with no success/meta", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "Success (200) JSON has top-level \"success\": true, a \"pagination\" block, and a \"meta\" object containing meta.source, meta.summary{count,avg_hold,min_hold,by_market,by_sport}, and meta.filters (lines 100-190). JS/Python examples are fine but the JSON block is the contract.", + "reality": "Handler encodes exactly {data, pagination, updated_at} (main.go:6984-6988). There is no top-level success field and no meta object at all (no source/summary/filters). pagination is the flat-paginated shape {limit,offset,count,total,has_more,next_offset[,next_cursor,warning]}.", + "evidence": "main.go:6984-6988 json.NewEncoder(w).Encode(map[...]{\"data\",\"pagination\",\"updated_at\"}); live: /api/v1/opportunities/low_hold?limit=2 -> top-level keys = data,pagination,updated_at (no success, no meta)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the Success (200) block: remove \"success\": true; remove the entire \"meta\" object (source/summary/filters do not exist); add \"updated_at\": \"\" as a top-level sibling of data/pagination. Pagination keys to show: limit, offset, count, total, has_more, next_offset (next_cursor is EV-only, not emitted here). This matches the other flat-paginated opportunity endpoints.", + "locale_scope": "replicates to all 4 locales (de/es/pt-BR mirror en)", + "unit": "opp-lowhold" + }, + { + "title": "Side object odds documented as nested odds.{american,decimal,implied_probability,fair_probability} but API emits FLAT odds_american/odds_decimal/odds_probability/fair_probability", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "Each side has a nested odds object: side1.odds.american, side1.odds.decimal, side1.odds.implied_probability, side1.odds.fair_probability (JSON lines 119-124, 134-139; Side Object table lines 274-277 list odds.american/odds.decimal/odds.implied_probability/odds.fair_probability). JS/Python examples read hold.side1.odds.american.", + "reality": "buildSide emits flat keys: odds_american (int), odds_decimal, odds_probability, fair_probability — there is no nested odds{} object. implied_probability is named odds_probability.", + "evidence": "transforms.go:1537-1548 (side map keys: selection, books, line, odds_american, odds_decimal, odds_probability, fair_probability, selection_id, market_id, external_event_id); live: side1 = {\"odds_american\":152,\"odds_decimal\":2.52,\"odds_probability\":0.3968,\"fair_probability\":0.3956,...}", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite both side examples and the Side Object table to use flat fields: odds_american (number), odds_decimal (number), odds_probability (number, 0-1 implied prob), fair_probability (number, no-vig). Update the JS example (s1.odds.american -> s1.odds_american etc.) and Python example likewise. The copy-paste JS/Python in the page currently print undefined / raise KeyError.", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "Documented per-side deep_links {book:url} does not exist on the low_hold response", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "Each side has a deep_links object mapping sportsbook name -> deep link URL or null (JSON lines 125-128, 140-143; Side Object table line 278; plus the info Callout lines 280-282 saying deep links are generated from the state param).", + "reality": "buildSide never sets deep_links; the only side keys are selection, books, line, odds_american, odds_decimal, odds_probability, fair_probability, selection_id, market_id, external_event_id (+ optional nested refs via addNestedRefs). Live response has no deep_links anywhere.", + "evidence": "transforms.go:1525-1551 (buildSide — no deep_links key); live: /api/v1/opportunities/low_hold?limit=2 sides contain no deep_links field", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the deep_links field from both side examples and the Side Object table. Either drop the 'Deep links are generated based on state' Callout and the state-param deep-link best-practice, or reword to clarify low_hold no longer emits per-side deep links (the state param is parsed but not surfaced on this endpoint).", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "401 error documents code \"unauthorized\" + wrong message/docs URL; real codes are missing_api_key / invalid_api_key", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "401 body: {error:{code:\"unauthorized\", message:\"Invalid or missing API key\", docs:\"https://docs.sharpapi.io/en/authentication\"}} (lines 205-214).", + "reality": "Missing key -> code missing_api_key, message \"API key required. Pass via X-API-Key header, api_key query parameter, or Bearer token.\", docs \"https://sharpapi.io/docs/authentication\". Bad key -> code invalid_api_key. \"unauthorized\" is reserved for admin/Bearer auth, never returned here.", + "evidence": "auth.go:1136-1138 (MissingAPIKey, message, docs URL); auth.go:1179/1250/1339 (InvalidAPIKey for bad key); errcodes.go:19/27 (invalid_api_key, missing_api_key)", + "current_status": "still-present", + "confidence": "high", + "fix": "Change 401 example code to missing_api_key (no key) or invalid_api_key (bad key); update message to the real text; fix docs URL to https://sharpapi.io/docs/authentication. Note 'unauthorized' is not a code this endpoint returns.", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "403 tier_restricted example omits the real tier + required_tier fields and uses wrong docs URL", + "severity": "LOW", + "category": "errcode", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "403 body: {error:{code:\"tier_restricted\", message:\"This endpoint requires Pro tier or higher\", docs:\"https://docs.sharpapi.io/en/pricing\"}} (lines 216-225).", + "reality": "Real body also includes tier (caller's tier) and required_tier (\"pro\"), and docs URL is https://sharpapi.io/pricing. Code and message match.", + "evidence": "tiers.go:333-341 (code TierRestricted, message, docs https://sharpapi.io/pricing, tier, required_tier)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add \"tier\":\"free\" and \"required_tier\":\"pro\" to the 403 example and change docs URL to https://sharpapi.io/pricing.", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "429 rate_limited example omits real fields (retry_after, retryAfter, tier, limit, remaining) and uses wrong docs URL", + "severity": "LOW", + "category": "errcode", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "429 body: {error:{code:\"rate_limited\", message:\"Rate limit exceeded\", docs:\"https://docs.sharpapi.io/en/api-reference/overview\"}} (lines 227-235).", + "reality": "Real body also includes retry_after (unix-ms reset timestamp), retryAfter (seconds), tier, limit, remaining; docs URL is https://sharpapi.io/docs/rate-limits. Code and message match. Retry-After header also set.", + "evidence": "ratelimit.go:124-129 (code RateLimited, message, docs https://sharpapi.io/docs/rate-limits, retry_after, retryAfter, tier, limit, remaining)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add retry_after/retryAfter/tier/limit/remaining to the 429 example and correct the docs URL to https://sharpapi.io/docs/rate-limits.", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "Pagination example omits the count field that the API always returns", + "severity": "LOW", + "category": "pagination", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "pagination block shows {limit, offset, total, has_more, next_offset} (lines 158-164).", + "reality": "API pagination always includes count (number of items in this page) alongside limit/offset/total/has_more/next_offset.", + "evidence": "main.go:6986 Pagination{Limit,Offset,Count:len(page),Total,HasMore,NextOffset,...}; live: pagination = {\"limit\":2,\"offset\":0,\"count\":2,\"total\":119,\"has_more\":true,\"next_offset\":2}", + "current_status": "still-present", + "confidence": "high", + "fix": "Add \"count\": to the pagination example (and note next_cursor/warning may appear; next_cursor is not emitted for low_hold since cursor paging is EV-only).", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "Response Fields table omits emitted fields league_label, market_label (root) and selection_id/market_id/external_event_id (side)", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "Response Fields table (lines 240-265) and Side Object table (lines 269-278) list a fixed field set and do not mention league_label, market_label, selection_id, market_id, external_event_id.", + "reality": "Root response also emits league_label and market_label; each side emits selection_id, market_id, external_event_id (often null). Additive omission, not an error, but undocumented.", + "evidence": "transforms.go:1580 league_label, 1582 market_label; 1545-1547 selection_id/market_id/external_event_id; live: root has league_label/market_label, side has selection_id/market_id/external_event_id", + "current_status": "still-present", + "confidence": "high", + "fix": "Optionally add league_label, market_label to the root field table and selection_id/market_id/external_event_id to the Side Object table (or note responses may include additional additive fields).", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "Example id value uses a human-readable string but real ids are 16-char hex hashes", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "Example id is \"lh_nba_lal_bos_ml_0\" (line 104).", + "reality": "id is a 16-char hex hash (or upstream hash_id). Field table correctly says 'hash', but the example value is misleading.", + "evidence": "transforms.go:1554-1562 (generateDeterministicId -> hash); live: \"id\":\"efd0b2c0ecc28aa8\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the example id to a 16-char hex value (e.g. \"efd0b2c0ecc28aa8\") to match the real hash format.", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "offset 'max 5000' is not enforced on this endpoint (offset just clamps to total)", + "severity": "LOW", + "category": "param-noop", + "page": "content/en/api-reference/opportunities-low-hold.mdx", + "docs_claim": "Query Parameters: offset 'Pagination offset (max 5000)' (line 36).", + "reality": "The opportunities handler clamps offset to total (offset>total => offset=total) and does not reject or cap at maxPaginationOffset=5000; any offset beyond results simply returns an empty page. So 'max 5000' is not a real bound here (unlike the /events family which uses maxPaginationOffset).", + "evidence": "main.go:6915-6917 (if offset>total {offset=total}); no maxPaginationOffset check in the opportunities handler (contrast main.go:5753 for events)", + "current_status": "still-present", + "confidence": "medium", + "fix": "Either drop the 'max 5000' note for offset (it clamps to total, not 5000) or note that offsets beyond the result count return an empty page. Limit 'max 200' is correctly enforced.", + "locale_scope": "replicates to all 4 locales", + "unit": "opp-lowhold" + }, + { + "title": "events.mdx Success response uses {data, meta:{count,total,pagination,filters}} envelope; reality is {data, pagination, updated_at}", + "severity": "CRITICAL", + "category": "envelope", + "page": "api-reference/events.mdx", + "docs_claim": "Success (200) sample wraps the list in a `meta` object: {data:[...], meta:{count:2, total:43, pagination:{limit,offset,has_more,next_offset}, updated_at, filters:{sport,league}}} (events.mdx:269-284). JS example reads `const {data, meta}` then meta.total/meta.count (lines 105-106, 208); Python reads data['meta']['total'] (line 123).", + "reality": "eventsHandler encodes a flat envelope {data, pagination:{limit,offset,count,total,has_more,next_offset,warning}, updated_at} — there is NO meta key, no nested pagination-inside-meta, no filters echo. The JS meta.total is undefined and the Python data['meta']['total'] raises KeyError.", + "evidence": "main.go:5896-5903 (encodes data/pagination/updated_at, no meta); live: /events?sport=baseball&limit=2 -> {\"data\":[...],\"pagination\":{\"limit\":2,\"offset\":0,\"count\":2,\"total\":981,\"has_more\":true,\"next_offset\":2},\"updated_at\":\"...\"} (no meta)", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the events.mdx Success(200) sample and both code examples to the flat shape {data:[...], pagination:{limit,offset,count,total,has_more,next_offset}, updated_at}. Replace meta.total->pagination.total, meta.count->pagination.count; drop the filters echo (not emitted). Remove the `const {data, meta}` destructure.", + "locale_scope": "Replicates to all 4 locales (de/es/pt-BR mirror en).", + "unit": "events" + }, + { + "title": "events-by-id.mdx Success response uses {data, meta:{event_id}} envelope; reality is {data, updated_at}", + "severity": "HIGH", + "category": "envelope", + "page": "api-reference/events-by-id.mdx", + "docs_claim": "Success(200) sample shows {data:{...}, meta:{event_id:\"nba_...\"}} (events-by-id.mdx:190-193). JS example destructures `const {data, meta}` (line 90).", + "reality": "eventDetailHandler encodes {data:, updated_at:} — there is NO meta object and the response does NOT echo event_id. meta and meta.event_id are undefined.", + "evidence": "main.go:8149-8152 (encodes {\"data\":ev,\"updated_at\":getStoreTimeISO()}); live: /events/mlb_american_league__cleguardians_2026-06-26_b3 -> {\"data\":{...},\"updated_at\":\"2026-06-27T00:35:02Z\"} (no meta)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the meta:{event_id} block in the Success sample with \"updated_at\": \"\". Remove meta from the JS destructure (line 90); the canonical id is already on data.id.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "401 error examples use code 'unauthorized'; reality emits 'missing_api_key' / 'invalid_api_key'", + "severity": "HIGH", + "category": "errcode", + "page": "api-reference/events.mdx, api-reference/events-by-id.mdx", + "docs_claim": "Both pages' 401 example bodies use {error:{code:\"unauthorized\", message:\"Missing or invalid API key\", docs:\".../authentication\"}} (events.mdx:289-298; events-by-id.mdx:209-218).", + "reality": "Auth middleware returns code missing_api_key when no key is supplied and invalid_api_key when the key is bad. The string `unauthorized` is reserved for admin/monitoring Bearer auth and is never returned for these apiKey endpoints.", + "evidence": "auth.go:1136 errcodes.MissingAPIKey; auth.go:1179,1250,1339 errcodes.InvalidAPIKey; errcodes.go:19,23; live: /events?limit=1 with no key -> {\"error\":{\"code\":\"missing_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"API key required. Pass via X-API-Key header, api_key query parameter, or Bearer token.\"}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the 401 example code to missing_api_key (no key) and note a bad key returns invalid_api_key. Update the message to match live wording. Same as prior-audit H6 class.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "Undocumented response fields: list events carry uuid and market_count", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/events.mdx", + "docs_claim": "The Event Object table (events.mdx:54-70) lists id/external_ids/sport/league/home_team/away_team/start_time/status/is_live/book_count/markets/books/game_state. It does NOT document uuid or market_count.", + "reality": "Each list row serializes an undocumented uuid (string) and market_count (integer). Both present on every row live.", + "evidence": "cache.go:42 UUID json:\"uuid\"; cache.go:53 MarketCount json:\"market_count\"; live: rows include \"uuid\":\"8dc9bac19736f54a\" and \"market_count\":1", + "current_status": "still-present", + "confidence": "high", + "fix": "Add uuid (string, internal stable id) and market_count (integer, distinct market-type count) rows to the Event Object table, or note them as internal/unstable.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "events.mdx documents a game_state object on list events; CachedEvent has no such field and live rows never emit it", + "severity": "MEDIUM", + "category": "field-name", + "page": "api-reference/events.mdx", + "docs_claim": "Event Object lists `game_state | object | undefined` (events.mdx:70) plus a full Game State Object sub-table with home_score/away_score/period/clock/score_type/possession/is_timeout/power_play/last_play (lines 72-86). has_score param documented to surface only events with live game state (line 31).", + "reality": "The CachedEvent struct backing /events list responses has no game_state field, so the list endpoint never returns a game_state object. Live game state is served by the separate /gamestate endpoint (add-on/Enterprise). The Game State Object sub-table is fictional for /events.", + "evidence": "cache.go:40-69 CachedEvent struct has no game_state/GameState field; live: /events?sport=baseball&limit=2 rows have no game_state key", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the game_state row and the Game State Object sub-table from events.mdx, or relocate to the /gamestate page and note that /events does not embed live state.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "events.mdx query params team and has_score are documented but are no-ops (never parsed or applied)", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/events.mdx", + "docs_claim": "Query Parameters table documents team (filter by team name, partial/alias match) and has_score (true = only events with live game state/scores) (events.mdx:31,33).", + "reality": "filters.ParseQuery does not read team or has_score, and eventsHandler never references a Team or HasScore filter (no such field exists in pkg/filters). Both params are silently ignored — passing team=lakers or has_score=true returns the unfiltered result set.", + "evidence": "pkg/filters/parse.go:67-214 (parses sport/league/sportsbook/market/live/q/date/date_range/sort/limit/offset/cursor only; no q.Get(\"team\")/q.Get(\"has_score\")); main.go:5733-5739 eventsHandler reads only Sport/League/Market/Sportsbook/Live/Q/Date/DateRange/Sort; grep for HasScore/.Team in pkg/filters returns none", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the team and has_score rows from the Query Parameters table (or implement them). The q param already covers team-name search; has_score belongs to /gamestate.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "events-by-id.mdx status enum lists final/postponed/cancelled; handler only ever emits upcoming|live", + "severity": "LOW", + "category": "field-name", + "page": "api-reference/events-by-id.mdx", + "docs_claim": "Event Detail Object documents status as `upcoming`, `live`, `final`, `postponed`, `cancelled` (events-by-id.mdx:48).", + "reality": "The events cache computes status as only upcoming (default) or live (>=2 books report live). final/postponed/cancelled are never assigned, so the detail endpoint never returns them. (events.mdx list page correctly documents only upcoming|live.)", + "evidence": "cache.go:267 status:=\"upcoming\"; cache.go:268-270 if isLive { status=\"live\" }; cache.go:353 Status:status (no other assignment to CachedEvent.Status)", + "current_status": "still-present", + "confidence": "high", + "fix": "Narrow the events-by-id status enum to upcoming | live to match the handler (and sibling events.mdx).", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "events-by-id.mdx 404 example includes a docs field the live handler does not emit", + "severity": "LOW", + "category": "errcode", + "page": "api-reference/events-by-id.mdx", + "docs_claim": "404 example: {error:{code:\"not_found\", message:\"Event not found\", docs:\"https://docs.sharpapi.io/en/api-reference/events-by-id\"}} (events-by-id.mdx:199-206).", + "reality": "The handler encodes only {error:{code:\"not_found\", message:\"Event not found\"}} — no docs key.", + "evidence": "main.go:8108-8109 (writes {\"error\":{\"code\":errcodes.NotFound,\"message\":\"Event not found\"}} with no docs field); live: /events/nonexistent_xyz -> {\"error\":{\"code\":\"not_found\",\"message\":\"Event not found\"}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the docs field from the 404 example (or note it is not always present).", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "events.mdx Success example is internally inconsistent: book_count:6 but books array has 4 entries", + "severity": "LOW", + "category": "example-breaks", + "page": "api-reference/events.mdx", + "docs_claim": "First example row shows book_count:6 with books:[\"betmgm\",\"caesars\",\"draftkings\",\"fanduel\"] (4 items); second row book_count:5 with 3-item books array (events.mdx:248-266).", + "reality": "Cosmetic mismatch — book_count should equal len(books). In reality both derive from the same accumulator (BookCount vs Books on CachedEvent) and always agree live (e.g. book_count:1 / books:[\"saba\"]).", + "evidence": "cache.go:52 BookCount, cache.go:55 Books (both from ev.books accumulator); live: book_count:1 matched books:[\"saba\"]", + "current_status": "still-present", + "confidence": "high", + "fix": "Make the example self-consistent: set book_count to match the array length, or list all books the count implies.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "events" + }, + { + "title": "events-odds: documented nested `odds:{american,decimal,odds_probability}` object does not exist — response is flat", + "severity": "CRITICAL", + "category": "field-name", + "page": "api-reference/events-odds.mdx", + "docs_claim": "Each odds item nests prices: schema rows `odds.american`/`odds.decimal`/`odds.probability` (L61-63) and the example JSON nests `\"odds\": { \"american\": -180, \"decimal\": 1.556, \"odds_probability\": 0.643 }` (L218-222, repeated L233-237, 248-252, 263-267, 278-282).", + "reality": "The handler serializes the flat `Odds` struct: top-level `odds_american` (int), `odds_decimal` (float), `odds_probability` (float). There is NO nested `odds` object anywhere in the response. Any client reading `item.odds.american` / `item.odds.decimal` / `item.odds.probability` gets undefined / KeyError.", + "evidence": "main.go:585-601 (Odds struct: OddsAmerican `json:\"odds_american\"`, OddsDecimal `json:\"odds_decimal\"`, OddsProbability `json:\"odds_probability\"`); live: /api/v1/events/mlb__athletics_2026-06-26_b3/odds -> item keys = [...,'odds_american','odds_decimal','odds_probability',...] with NO 'odds' key.", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the nested `odds` object in the schema table (L61-63) and every example JSON block with three flat top-level fields: `odds_american` (number), `odds_decimal` (number), `odds_probability` (number). Note: prior audit H22 mis-diagnosed this as 'nested odds.probability is correct' — it is not; there is no nested object. While editing, also add the many real fields the example omits (see separate finding).", + "locale_scope": "all 4 locales (en L61-63/218-282; de; es; pt-BR L218-282) mirror this verbatim", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: response envelope documented with `meta` + pagination; real envelope is `{data, updated_at}` with no meta/pagination", + "severity": "CRITICAL", + "category": "envelope", + "page": "api-reference/events-odds.mdx", + "docs_claim": "Response (L286-301) wraps a `meta` object: `meta.count`, `meta.total`, `meta.pagination:{limit,offset,has_more,next_offset}`, `meta.updated_at`, `meta.filters:{sportsbook,market}`. Example code destructures `const {data, meta}` and reads `meta.total` (L84-85, L98), `meta.pagination.has_more` / `meta.pagination.next_offset` (L166-171, L194-196).", + "reality": "The isOdds branch returns ONLY `{\"data\": [...], \"updated_at\": \"...\"}`. There is no `meta`, no `count`, no `total`, no `pagination`, no `filters` echo. Every JS/Python example throws: `meta` is undefined → `meta.total` TypeError; `result['meta']` KeyError; the pagination while-loops never terminate / crash on `meta.pagination`.", + "evidence": "main.go:8086-8090 `json.NewEncoder(w).Encode(map[string]interface{}{\"data\": result, \"updated_at\": getStoreTimeISO()})`; live: /api/v1/events//odds -> TOP KEYS = ['data','updated_at'] only.", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the Response block to `{data:[...], updated_at}` (no meta/pagination/filters). Fix all three example tabs (single fetch, filter, paginate) to stop destructuring `meta`; the paginate example must be removed or rewritten as a single un-paginated call since the endpoint returns the full set. Use the actual flat item shape.", + "locale_scope": "all 4 locales (en L84-98,166-196,286-301; pt-BR L85 confirmed; de/es mirror)", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: documented query params sportsbook/market/limit/offset are silently ignored (param-noop)", + "severity": "HIGH", + "category": "param-noop", + "page": "api-reference/events-odds.mdx", + "docs_claim": "Query Parameters table (L31-36) lists `sportsbook` (comma-sep filter), `market` (comma-sep filter), `limit` (default 50, max 200), `offset` — and example requests pass `?sportsbook=draftkings,pinnacle&market=moneyline,spread` and `?limit=50&offset=50`.", + "reality": "The isOdds branch parses NONE of these — it scans every book in the event's cached index (tier-filtered) and returns all matching odds. limit/offset/sportsbook/market have zero effect. (Server-side book filtering is by TIER entitlement, not the `sportsbook` param.)", + "evidence": "main.go:8048-8091 (no r.URL.Query() read for sportsbook/market/limit/offset; only tierAllowed + delayCutoff applied); live: `?limit=1` returned 2 items (full set); `?sportsbook=draftkings` returned books ['betonline','saba'] (filter ignored).", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the Query Parameters table from events-odds.mdx (the endpoint takes none), or explicitly state these are not supported and direct users to `GET /api/v1/odds?event={id}&sportsbook=...&limit=...` for filtering/pagination. Remove the 'Filter by sportsbook and market' and 'Paginate' example tabs.", + "locale_scope": "all 4 locales (en L31-36,103-201; de/es/pt-BR mirror)", + "unit": "events-odds-markets" + }, + { + "title": "events-markets: documented `selections[]` sub-array and Selection Object do not exist", + "severity": "CRITICAL", + "category": "field-missing", + "page": "api-reference/events-markets.mdx", + "docs_claim": "Market Object lists `selections` (array of Selection objects) (L44); a full 'Selection Object' table (L46-54) documents `name`/`type`/`line`/`book_count`; example JSON nests `\"selections\": [{name,type,book_count,line}]` per market (L114-157); JS/Python examples iterate `market.selections` / `market['selections']` and read `sel.name`/`sel.type`/`sel.line`/`sel.book_count` (L76-79, L95-97).", + "reality": "MarketResult has NO selections array. Fields are: `market_type`, `market_label`, `selection_count` (int), `book_count` (int), `books` ([]string of sportsbook ids). There is no per-selection breakdown. JS/Python iterating `market.selections` throw (undefined is not iterable / KeyError).", + "evidence": "endpoints.go:746-752 (MarketResult{MarketType,MarketLabel,SelectionCount,BookCount,Books}); live: /api/v1/events//markets -> market keys = ['market_type','market_label','selection_count','book_count','books'].", + "current_status": "still-present", + "confidence": "high", + "fix": "Delete the Selection Object section and the `selections` row from Market Object. Document the real fields: `market_type`, `market_label` (human label), `selection_count`, `book_count`, `books` (array of sportsbook id strings). Rewrite example JSON + JS/Python to use these (no per-selection loop).", + "locale_scope": "all 4 locales (en L44-54,76-97,114-157; de L44-95; es L44-95 confirmed; pt-BR mirror)", + "unit": "events-odds-markets" + }, + { + "title": "events-markets: response wrapped in `{success:true,...,meta}`; real envelope is `{data, updated_at}`", + "severity": "HIGH", + "category": "envelope", + "page": "api-reference/events-markets.mdx", + "docs_claim": "Response (L107-165) is `{\"success\": true, \"data\": [...], \"meta\": {event_id, count, updated_at}}`; JS/Python destructure `const {data, meta}` (L71).", + "reality": "Handler returns `{\"data\": [...], \"updated_at\": \"...\"}` only — no `success`, no `meta` (so no meta.event_id / meta.count). `meta` is undefined in the JS example.", + "evidence": "endpoints.go:778-781 `Encode(map[string]interface{}{\"data\": result, \"updated_at\": getStoreTimeISO()})`; live: TOP KEYS = ['data','updated_at'].", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace Response block with `{data:[...], updated_at}`; drop `success` and `meta`; remove `meta` from the JS destructure (L71). (Prior audit H21 flagged this; still unfixed.)", + "locale_scope": "all 4 locales (en L71,107-165; de L108; es L108 confirmed; pt-BR mirror)", + "unit": "events-odds-markets" + }, + { + "title": "events-markets: field named `type`; real field is `market_type` (+ undocumented `market_label`, `books[]`)", + "severity": "HIGH", + "category": "field-name", + "page": "api-reference/events-markets.mdx", + "docs_claim": "Market Object field is `type` (L41); examples read `market.type` / `market['type']` (L75, L94) and example JSON uses `\"type\": \"moneyline\"`/`\"point_spread\"`/`\"total_points\"` (L111,120,129,...).", + "reality": "Field is `market_type`, not `type`. There is also an undocumented `market_label` (human-readable label, e.g. 'Outright') and an undocumented `books` []string array. Reading `market.type` yields undefined; `market_type` values are canonical snake_case (`moneyline`, `total`, `spread`, `player_prop`, ...) not `point_spread`/`total_points`.", + "evidence": "endpoints.go:747 `MarketType string json:\"market_type\"`, :748 `MarketLabel json:\"market_label\"`, :751 `Books []string json:\"books\"`; live market: market_type='outright', market_label='Outright', books=['betonline'].", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename `type` → `market_type` everywhere (table, examples, JSON). Add `market_label` and `books` (array of sportsbook ids) rows. Replace the invented `point_spread`/`total_points` values with real canonical ids (`spread`, `total`, etc.). (Prior audit H21 flagged the field-name; still unfixed.)", + "locale_scope": "all 4 locales (en L41,75,94,111-157; de/es L75,94,111; pt-BR mirror)", + "unit": "events-odds-markets" + }, + { + "title": "Both pages: 401 example uses code `unauthorized` + message 'Missing or invalid API key'; real codes are missing_api_key / invalid_api_key", + "severity": "MEDIUM", + "category": "errcode", + "page": "api-reference/events-odds.mdx + api-reference/events-markets.mdx", + "docs_claim": "401 example: `{\"error\":{\"code\":\"unauthorized\",\"message\":\"Missing or invalid API key\",\"docs\":\".../authentication\"}}` (events-odds L317-326; events-markets L181-189).", + "reality": "On these apiKey routes a missing key returns code `missing_api_key` (message 'API key required. Pass via X-API-Key header, api_key query parameter, or Bearer token.') and a bad key returns `invalid_api_key` (message 'Invalid API key'). `unauthorized` is reserved for admin/monitoring Bearer auth and is never returned here.", + "evidence": "auth.go:1136 (MissingAPIKey + that message), auth.go:1250/1339 (InvalidAPIKey 'Invalid API key'); pkg/errcodes/errcodes.go:19,23 confirm code strings; errcodes.go:31 `Unauthorized=\"unauthorized\"` exists but is not used on apiKey routes.", + "current_status": "still-present", + "confidence": "high", + "fix": "Split the 401 example into two: missing key -> code `missing_api_key`; invalid key -> code `invalid_api_key`, with the real messages. Remove `unauthorized`.", + "locale_scope": "all 4 locales (events-odds L317-326, events-markets L181-189 + de/es/pt-BR)", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: 429 example code `rate_limited` with no retry_after; real shape adds retry_after/retryAfter", + "severity": "LOW", + "category": "errcode", + "page": "api-reference/events-odds.mdx", + "docs_claim": "429 example: `{\"error\":{\"code\":\"rate_limited\",\"message\":\"Rate limit exceeded. Upgrade your tier for higher limits.\",\"docs\":\".../pricing\"}}` (L329-336).", + "reality": "Code `rate_limited` is correct, but the real 429 body message is 'Rate limit exceeded' and includes `retry_after` (Unix ms timestamp) and a legacy `retryAfter` (seconds) field; response also sets Retry-After + X-RateLimit-* headers. The example omits the retry fields.", + "evidence": "ratelimit.go:124-128 (code RateLimited, message 'Rate limit exceeded', retry_after=resetMs, retryAfter=seconds); pkg/errcodes/errcodes.go:27.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add `retry_after` (unix ms) to the 429 example and correct the message to 'Rate limit exceeded' (or keep an upgrade hint as a separate note). Low priority — code is right and clients rarely parse the message.", + "locale_scope": "all 4 locales (en L329-336 + de/es/pt-BR)", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: 'convenience endpoint equivalent to /odds?event=' is inaccurate (different handler, different envelope/behavior)", + "severity": "MEDIUM", + "category": "conceptual", + "page": "api-reference/events-odds.mdx", + "docs_claim": "Callout (L15-17): 'This is a convenience endpoint equivalent to `GET /api/v1/odds?event={eventId}`. Both return the same data; use whichever fits your URL structure.'", + "reality": "They are different handlers with different contracts. `/odds?event=` returns `{data, pagination, updated_at}` and DOES honor sportsbook/market/limit/offset/cursor filtering & pagination; `/events/{id}/odds` returns `{data, updated_at}` (no pagination) and ignores all those params. The underlying odds rows overlap, but envelope + filtering behavior differ — so the documented params 'work' only on `/odds?event=`, not here.", + "evidence": "main.go:8048-8091 (events-odds, no params, {data,updated_at}); live /api/v1/odds?event= -> TOP KEYS ['data','pagination','updated_at'] with pagination object; live /api/v1/events//odds -> ['data','updated_at'].", + "current_status": "still-present", + "confidence": "high", + "fix": "Reword the Callout: '/events/{id}/odds returns all odds for the event as `{data, updated_at}` with no pagination or filter params. For sportsbook/market filtering or pagination, use `GET /api/v1/odds?event={id}`.'", + "locale_scope": "all 4 locales (en L15-17 + de/es/pt-BR)", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: example/schema omit many real fields (event_uuid, sport, league, teams, is_active, is_main_line, is_alternate_line, is_stale_pregame_price, is_player_prop, deep_link, market_id, selection_id, nested refs, external_event_id)", + "severity": "LOW", + "category": "field-missing", + "page": "api-reference/events-odds.mdx", + "docs_claim": "Odds Object table (L52-64) lists only id, sportsbook, event_id, market_type, selection, selection_type, line, odds.*, timestamp.", + "reality": "Live items carry many more stable top-level fields: event_uuid, sport, league, home_team, away_team, is_live, is_active, is_main_line, is_alternate_line, is_stale_pregame_price, is_player_prop, deep_link, market_id, selection_id, plus optional nested home/away/sport_ref/league_ref/market_ref/sportsbook_ref and external_event_id. Degrades gracefully (extra fields), but the schema is materially incomplete.", + "evidence": "main.go:585-647 (Odds struct fields incl is_active L614, is_alternate_line L647, is_main_line, timestamp L620, deep_link/market_id/selection_id L635-637); live item keys list includes all of the above + 'external_event_id','home','sport_ref','league_ref','market_ref','sportsbook_ref'.", + "current_status": "new", + "confidence": "high", + "fix": "Expand the Odds Object table to document the real stable fields (at minimum is_live, is_active, is_main_line, is_alternate_line, is_player_prop, deep_link, market_id, selection_id, event_uuid, sport, league, home_team, away_team) and note optional nested ref objects. Not flagged in prior audit for this page.", + "locale_scope": "all 4 locales (en L52-64 + de/es/pt-BR)", + "unit": "events-odds-markets" + }, + { + "title": "sports.mdx Response shows a fabricated `meta`/`pagination` envelope; real envelope is {data, updated_at}", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/sports.mdx", + "docs_claim": "Response JSON (lines 128-140) wraps data in `\"meta\": { \"count\":19, \"total\":19, \"pagination\":{ \"limit\":50, \"offset\":0, \"has_more\":false, \"next_offset\":null }, \"updated_at\":..., \"filters\":{} }`. JS example (line 37) destructures `const { data, meta }`.", + "reality": "sportsHandler returns ONLY `{\"data\":[...], \"updated_at\":...}` — no `meta`, no `pagination`, no `count`/`total`/`filters`. /sports is a flat non-paginated endpoint and does not paginate at all.", + "evidence": "main.go:6136-6139 (json.NewEncoder().Encode of map{data, updated_at}); live: /api/v1/sports -> {\"data\":[{\"id\":\"soccer\",\"name\":\"Soccer\",...}], \"updated_at\":...} with no meta key", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the example envelope with `{\"data\":[...], \"updated_at\":\"...\"}`. Remove the entire `meta` block (count/total/pagination/filters). Stop destructuring `meta` in the JS example (line 37) — it is always undefined.", + "locale_scope": "en + replicates to de, es, pt-BR (translated from same source)", + "unit": "sports-leagues" + }, + { + "title": "leagues.mdx Response shows a fabricated `meta`/`pagination` envelope; real envelope is {data, updated_at}", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/leagues.mdx", + "docs_claim": "Both Response JSON blocks (lines 101-115 and 236-248) wrap data in `\"meta\": { count, total, pagination:{limit:50,offset,has_more,next_offset}, updated_at, filters:{sport} }`. JS example (line 45) destructures `const { data, meta }`.", + "reality": "leaguesHandler returns ONLY `{\"data\":[...], \"updated_at\":...}` — no `meta`, no `pagination`, no `filters` echo. /leagues does not paginate.", + "evidence": "main.go:7937-7940 (Encode map{data, updated_at}); live: /api/v1/leagues?sport=baseball -> {\"data\":[...],\"updated_at\":...} with no meta key", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace both example envelopes with `{\"data\":[...], \"updated_at\":\"...\"}`. Remove the `meta` blocks. Drop `meta` from the JS destructure (line 45).", + "locale_scope": "en + replicates to de, es, pt-BR", + "unit": "sports-leagues" + }, + { + "title": "leagues.mdx uses `name`; real league field is `display_name` — JS/Python examples read a nonexistent field", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/leagues.mdx", + "docs_claim": "Response examples show `\"name\": \"NBA\"` (lines 79, 87, etc.), the schema table calls the field `name` (line 257: `| name | string | Human-readable league name |`), and JS/Python read `league.name`/`league['name']` (lines 48, 64).", + "reality": "The League struct serializes `DisplayName` as JSON `\"display_name\"`; there is NO `name` field. Live row: `{\"id\":\"mlb\",\"display_name\":\"MLB\",\"numerical_id\":354,\"sport\":\"baseball\",\"event_count\":637,\"live_count\":16}`.", + "evidence": "main.go:7862 (`DisplayName string `json:\"display_name\"``); live: /api/v1/leagues?sport=baseball -> rows use \"display_name\", no \"name\" key", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename every league `name` to `display_name` in both Response JSON blocks, the schema table (line 257), and the JS (line 48: `league.display_name`) + Python (line 64: `league['display_name']`) examples.", + "locale_scope": "en + replicates to de, es, pt-BR", + "unit": "sports-leagues" + }, + { + "title": "leagues.mdx documents `limit`/pagination that the endpoint ignores", + "severity": "MEDIUM", + "category": "pagination", + "page": "content/en/api-reference/leagues.mdx", + "docs_claim": "Response examples advertise a `pagination` object with `limit:50` and `next_offset`, implying the endpoint paginates with a configurable limit.", + "reality": "leaguesHandler accepts no `limit`/`offset` and returns every matching league in one response. Live `?sport=baseball&limit=3` returned the FULL baseball league list (20+ rows), not 3 — `limit` is silently dropped.", + "evidence": "main.go:7845-7941 (handler parses only filters.ParseQuery sport/sportsbook; no limit/offset, no slicing); live: /api/v1/leagues?sport=baseball&limit=3 -> returned all baseball leagues (mlb, mlb_national_league, npb, mlb_world_series, ... well past 3)", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the pagination/limit narrative entirely (this is covered by removing the fabricated `meta` block). Do not document a `limit` parameter for /leagues.", + "locale_scope": "en + replicates to de, es, pt-BR", + "unit": "sports-leagues" + }, + { + "title": "sports.mdx / leagues.mdx schema say numerical_id is `integer | null`; it is omitted (omitempty), never null", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/sports.mdx, content/en/api-reference/leagues.mdx", + "docs_claim": "Both schema tables type `numerical_id` as `integer | null` and say it is `absent (or null)` for unmapped entities (sports.mdx:148,159; leagues.mdx:256,267).", + "reality": "Both structs use `int `json:\"numerical_id,omitempty\"`` so an unmapped entity OMITS the key entirely — it never serializes as `null` (and `0` would also be omitted). Live confirms: mapped `mlb` carries `\"numerical_id\":354` but unmapped `mlb_national_league`/`mlb_world_series` have NO numerical_id key.", + "evidence": "main.go:6034 (sport `NumericalID int `json:\"numerical_id,omitempty\"``), main.go:7863 (league same); live: /api/v1/leagues?sport=baseball -> mlb_national_league row has no numerical_id key (not null)", + "current_status": "still-present", + "confidence": "high", + "fix": "Change type to `integer (optional)` and reword to 'omitted when unmapped' — remove the `| null` / `or null` wording in both schema tables and both 'New (May 2026)' notes.", + "locale_scope": "en + replicates to de, es, pt-BR", + "unit": "sports-leagues" + }, + { + "title": "leagues.mdx 'Common Leagues' overstates catalog size (~1,000 / 600+ across 30+ sports) vs reality", + "severity": "LOW", + "category": "conceptual", + "page": "content/en/api-reference/leagues.mdx", + "docs_claim": "Line 264: 'The full registry currently spans ~1,000 leagues across all 19 sports.' Line 274: 'catalogs 600+ leagues across 30+ sports.' Two different counts on the same page, and '30+ sports' contradicts '19 sports'.", + "reality": "The static sport display map defines 26 canonical sports (sportDisplayNames), and the live sports list is driven by actual odds, not a fixed 19/30. The two league counts (~1,000 vs 600+) and the two sport counts (19 vs 30+) are internally inconsistent illustrative claims. Cannot confirm an exact league total from the static code (atlas-driven), but the numbers conflict with each other and with the '19 sports' figure used elsewhere in these docs.", + "evidence": "leagues.mdx:264 ('~1,000 leagues across all 19 sports') vs leagues.mdx:274 ('600+ leagues across 30+ sports'); sportsbooks.go:144-156 defines 26 canonical sport ids", + "current_status": "still-present", + "confidence": "medium", + "fix": "Reconcile to one figure and one sport count, or drop the hard numbers and keep only 'call GET /api/v1/leagues for the canonical, up-to-date list'. At minimum make the sport count consistent (19 vs 30+).", + "locale_scope": "en + replicates to de, es, pt-BR", + "unit": "sports-leagues" + }, + { + "title": "sports.mdx 'Available Sports' table lists ids/names that don't match the canonical sport map", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/sports.mdx", + "docs_claim": "Table (lines 166-186) lists `rugby` (Rugby Union), `motorsports` (Motorsports), `curling` (Curling), and claims '19 sports' (meta.count:19 at line 129).", + "reality": "The canonical sportDisplayNames map uses `rugby_union` (no `rugby`), `motorsport` singular (sportShardAliases conversely uses `motorsports`), and has NO `curling` entry; it also includes volleyball, handball, cycling, aussie_rules, water_polo, politics, entertainment, weather that the table omits. Sport rows are emitted from live odds (o.sportLower), so the actual id set is data-driven, but the documented canonical ids `rugby`/`curling` are not what the display map keys on. Live top sport id was `soccer` with numerical_id 24 — the table's hard-coded numerical_ids (basketball:1, football:2, baseball:3, hockey:4) are also illustrative.", + "evidence": "sports.mdx:178 (`rugby` | Rugby Union), :181 (`motorsports`), :182 (`curling`); sportsbooks.go:144-156 keys: rugby_union, motorsport, no curling; live: /api/v1/sports top row id=soccer numerical_id=24", + "current_status": "still-present", + "confidence": "medium", + "fix": "Regenerate the Available Sports table from a live /sports response (or align ids to sportDisplayNames: rugby_union, motorsport). Mark numerical_id and counts as illustrative, or drop the hard-coded values.", + "locale_scope": "en + replicates to de, es, pt-BR", + "unit": "sports-leagues" + }, + { + "title": "markets.mdx response example invents a meta/rate_limit block; real envelope is {data, updated_at}", + "severity": "MEDIUM", + "category": "envelope", + "page": "content/en/api-reference/markets.mdx", + "docs_claim": "Success (200) JSON shows a top-level meta:{count, total_events, total_selections, rate_limit:{limit,remaining,reset}} alongside data (lines 175-185).", + "reality": "marketsHandler encodes ONLY {data, updated_at}. No meta, no count/total_events/total_selections, no rate_limit object.", + "evidence": "main.go:7954-7957 (json.NewEncoder ...{data,updated_at}); live: /markets -> TOP-LEVEL KEYS ['data','updated_at']", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the example meta block with updated_at (ISO8601). Remove count/total_events/total_selections/rate_limit (rate-limit info is in X-RateLimit-* headers, not the body).", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "markets.mdx invents description/hasLine/examples/selection_count fields that do not exist in the API", + "severity": "HIGH", + "category": "field-missing", + "page": "content/en/api-reference/markets.mdx", + "docs_claim": "Each market object has description (string), hasLine (boolean), examples (string[]), selection_count (number) - shown in example JSON (lines 85-89), the schema table (lines 196-199), and consumed by JS/Python examples (m.hasLine, m.examples, market['hasLine'], selection_count).", + "reality": "CachedMarket has exactly {id, name, numerical_id (omitempty), event_count, sports}. None of description/hasLine/examples/selection_count exist. The JS example reads m.examples.length / m.hasLine and the Python example reads market['hasLine'] / market['examples'] - both throw TypeError/KeyError on the real response.", + "evidence": "cache.go:1937-1943 (CachedMarket struct: ID,Name,NumericalID,EventCount,Sports only); live: /markets first item keys = ['id','name','numerical_id','event_count','sports']", + "current_status": "still-present", + "confidence": "high", + "fix": "Drop description/hasLine/examples/selection_count from the example, the Market Object Schema table, and both code samples. Rewrite JS/Python loops to use only {id, name, event_count, sports}. hasLine can stay as prose under 'Understanding Lines' but not as a response field.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "markets.mdx example/tables use market ids future, 1st_half, 1st_quarter that do not exist in the live catalog", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/api-reference/markets.mdx", + "docs_claim": "Example and Market-Types tables list market ids future, 1st_half, 1st_quarter (lines 145-173, 240-247) as filterable market ids.", + "reality": "Live /markets catalog (405 entries) has NO future/1st_half/1st_quarter. The top market is outright (never mentioned in the doc). Real period markets are sport-specific composite ids like 1st_half_total_goals. ?market=future or ?market=1st_half returns nothing.", + "evidence": "live: /markets -> future ABSENT, 1st_half ABSENT, 1st_quarter ABSENT, outright PRESENT (events 24794, #1 market); top ids = outright, correct_score, moneyline, point_spread, total_goals, total_points, 1st_half_total_goals, team_total_goals", + "current_status": "still-present", + "confidence": "high", + "fix": "Regenerate the example and Market Types tables from a live /markets pull. Add outright (the dominant market). Replace standalone future/1st_half/1st_quarter with real ids or clearly label period markets as sport-specific composites.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "markets.mdx numerical_id described as null when absent; the API omits the key entirely", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/markets.mdx", + "docs_claim": "Schema row: numerical_id | integer | null and 'absent (or null) for niche markets' (lines 193, 207).", + "reality": "CachedMarket.NumericalID has json tag numerical_id,omitempty - when 0/unassigned the field is OMITTED from the JSON, never serialized as null.", + "evidence": "cache.go:1940 (NumericalID int json:\"numerical_id,omitempty\")", + "current_status": "still-present", + "confidence": "high", + "fix": "Change 'or null' to 'omitted' for the absent case; keep 'integer' as the present-value type. (Same omitempty nuance on sportsbooks.mdx.)", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx response example fabricates a meta/pagination/filters block; real envelope is {data, updated_at}", + "severity": "MEDIUM", + "category": "envelope", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Response JSON shows top-level meta:{count, total, pagination:{limit,offset,has_more,next_offset}, updated_at, filters:{}} (lines 296-307).", + "reality": "sportsbooksHandler encodes ONLY {data, updated_at}. No meta wrapper, no pagination, no filters, no count/total. /sportsbooks is NOT paginated. The JS example also destructures const { data, meta } (line 37) - meta is undefined.", + "evidence": "main.go:6020-6023 (encode {data,updated_at}); live: /sportsbooks -> TOP-LEVEL KEYS ['data','updated_at']", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the meta block with a top-level updated_at. Remove meta/pagination/filters from the example and fix the JS sample to const { data } = await response.json().", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx documents and shows a name field that the API does not return", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Example rows include \"name\": \"draftkings\" etc. (lines 73,87,101) and the schema table documents name | string | Internal name (same as id) (line 318).", + "reality": "CachedSportsbook has no Name field. Live row keys are id, display_name, short_name, numerical_id, category, is_sharp, has_live_odds, has_player_props, regions, requires_tier, status, event_count, last_update, coming_soon - no name.", + "evidence": "cache.go:2154-2169 (struct has DisplayName/ShortName, no Name); live: /sportsbooks first item 'has name field? False'", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove name from every example row and from the schema table. Use id for the internal slug. (Python example line 61 already uses display_name.)", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx omits the live category and short_name fields from the schema", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Schema table (lines 313-326) lists id, numerical_id, name, display_name, has_live_odds, has_player_props, regions, is_sharp, status, event_count, last_update, requires_tier - no category, no short_name.", + "reality": "Every live row carries category (values: regional, exchange, sharp, major_us) and short_name, both real struct fields a client can filter on (?category= is a working filter). The docs never mention either, and example rows omit them.", + "evidence": "cache.go:2156-2159 (ShortName, Category json fields); main.go:5975 (?category filter uses sb.Category); live: /sportsbooks rows include category and short_name on every book", + "current_status": "still-present", + "confidence": "high", + "fix": "Add short_name (string) and category (string: major_us|sharp|exchange|regional) rows to the schema table and include them in example rows. The ?category= filter note only makes sense once category is documented.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx says requires_tier is null for free books; the API returns the string \"free\"", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Example rows show \"requires_tier\": null for free books (draftkings, fanduel, rebet, betway, skybet, polymarket, kalshi). Schema says null (free) (line 326) and the table maps null = Available on all tiers (line 364). JS filters b.requires_tier === 'pro'; Python uses if book['requires_tier'] truthiness.", + "reality": "requires_tier is NEVER null in live; free books carry the literal string \"free\". Live distinct values: free, hobby, pro, sharp. The Python truthiness test prints '(requires free)' for free books instead of '', and any client treating null as the free sentinel mis-buckets every free book.", + "evidence": "cache.go:2164 (RequiresTier string json:\"requires_tier\" - no omitempty); cache.go:2216 default requiresTier := \"free\"; live: requires_tier distinct = ['free','hobby','pro','sharp'], 4 books = 'free'", + "current_status": "still-present", + "confidence": "high", + "fix": "Change all example requires_tier: null to \"free\". Schema row: type string with values free|hobby|pro|sharp. Fix the table to map \"free\" = available on all tiers. Update the Python sample so it doesn't print '(requires free)'.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx numerical_id described as null when unmapped; the API omits the key", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Schema: numerical_id | integer | null and 'absent (or null) for sportsbooks not yet mapped' (lines 316, 337).", + "reality": "CachedSportsbook.NumericalID is numerical_id,omitempty - unmapped books drop the key entirely (live: 7 of 34 books have no numerical_id key: sportzino, eighteenbet, bwin, sportingbet_br, circa, sx_bet, Bet365 US). Not serialized as null.", + "evidence": "cache.go:2158 (NumericalID int json:\"numerical_id,omitempty\"); live: 27 books WITH numerical_id, 7 WITHOUT (key omitted)", + "current_status": "still-present", + "confidence": "high", + "fix": "Say 'omitted' rather than 'null' for the unmapped case.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx example/tables list a wrong & stale book inventory", + "severity": "MEDIUM", + "category": "conceptual", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Example JSON includes espnbet (display 'theScore Bet'), betfair (is_sharp), betano, unibet_*; AUTO tables list Bet365 UK, unibet_be/ca/de/dk/ro/se, betano, betsson, sx_bet.", + "reality": "Live active books (34): Bet365 US, ballybet, betmgm, betonline, betrivers, betway, bookmaker, bovada, bwin, caesars, circa, draftkings, eighteenbet, fanatics, fanduel, fliff, hardrock, kalshi, ladbrokes, novig, onexbet, pinnacle, polymarket, prophetx, rebet, saba, sbobet, skybet, sportingbet_br, sportzino, stake, sx_bet, thescorebet, unibet. No espnbet, no betfair, no betano, no Bet365 UK, no per-country unibet_*, no betsson. Live-only books absent from doc: eighteenbet, sportingbet_br, thescorebet, onexbet, sbobet, saba.", + "evidence": "live: /sportsbooks all 34 ids enumerated (no espnbet/betfair/betano/unibet_xx); is_sharp set = bookmaker,circa,onexbet,pinnacle,prophetx,sbobet (betfair NOT present)", + "current_status": "still-present", + "confidence": "high", + "fix": "Regenerate the example rows and the AUTO:* book tables from a live /sportsbooks pull. Remove espnbet, betfair, betano, betsson, Bet365 UK, unibet_*; add eighteenbet, sportingbet_br, thescorebet, onexbet, sbobet, saba. Run the AUTO-block generator implied by the AUTO:START/END markers.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx assigns wrong tiers to specific books (betrivers, bet365, kalshi/polymarket)", + "severity": "HIGH", + "category": "tier", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Example shows betrivers requires_tier=\"pro\" (line 149) and bet365 requires_tier=\"pro\" (line 215); kalshi/polymarket shown with requires_tier=null (free) in example (lines 280,293) but Hobby in the prediction-books table (lines 447-448).", + "reality": "Live: betrivers requires_tier=hobby (not pro); the id is 'Bet365 US' (requires_tier=hobby), there is NO 'bet365' id; kalshi=hobby, polymarket=hobby (the example null contradicts both reality and the doc's own table). Mis-stating required tier misleads customers about whether their plan can read a book.", + "evidence": "live: betrivers tier=hobby; bet365 NOT IN LIVE / 'Bet365 US' tier=hobby; kalshi tier=hobby; polymarket tier=hobby (example shows null)", + "current_status": "still-present", + "confidence": "high", + "fix": "Set betrivers and bet365(=Bet365 US) to hobby in the example; reconcile kalshi/polymarket to hobby everywhere; rename the bet365 example/table id to 'Bet365 US'. Regenerate from live to avoid future drift.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx AUTO tier-summary book counts are wrong (Free '13', stale totals, Bet365 UK)", + "severity": "MEDIUM", + "category": "tier", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "tier-summary table: Free=13 books, Hobby=34, Pro=35, Sharp=41, Enterprise=41 (lines 347-354). Lists 'Bet365 UK' under Hobby books.", + "reality": "Only 4 books carry requires_tier=free; access entitlement caps Free at MaxBooks=2 (whitelist draftkings,fanduel + 60s delay) per tiers.go - neither interpretation yields 13. Live active total is 34 (not 41). Bet365 UK is retired (not in live). Sharp MaxBooks=25 (NOT 'all 41'); only enterprise is unlimited.", + "evidence": "live: requires_tier=free count = 4 (draftkings,fanduel,eighteenbet,sportingbet_br); live active books total = 34; brief/tiers.go: Free MaxBooks=2 whitelist, Sharp MaxBooks=25", + "current_status": "still-present", + "confidence": "medium", + "fix": "Regenerate the AUTO:tier-summary from live. Distinguish 'books gated at this tier' (requires_tier) from 'books a key may concurrently read' (MaxBooks: free 2, hobby 5, pro 15, sharp 25, enterprise unlimited). Drop Bet365 UK. Fix total from 41 to the live active count.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx frontmatter says 'Covers 32 books'; live is 34 active", + "severity": "LOW", + "category": "tier", + "page": "content/en/api-reference/sportsbooks.mdx", + "docs_claim": "Frontmatter: 'Covers 32 books including Pinnacle and DraftKings.' (line 2). Example meta.count/total = 17.", + "reality": "Live /sportsbooks returns 34 active books (more in ?catalog=true). 32 and 17 are both stale.", + "evidence": "live: /sportsbooks -> NUM BOOKS (active): 34", + "current_status": "still-present", + "confidence": "high", + "fix": "Update frontmatter to the live count (or make it count-agnostic). The example meta.count is moot once the meta block is removed.", + "locale_scope": "all 4 locales (de/es/pt-BR identical)", + "unit": "markets-sportsbooks" + }, + { + "title": "Response envelope is {data, pagination, updated_at}, not {data, meta}", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Success (200) example (lines 89-148) and the entire 'Meta Object' section (179-189) document a top-level `meta` object: meta:{count, sport_filter, league_filter, query, total_events, total_live, updated}. JS example destructures `const { data, meta }` (line 56); Python reads `result['meta']['count']` (line 77).", + "reality": "Handler returns {data, pagination:{limit,offset,count,total,has_more,next_offset}, updated_at}. There is NO `meta` object and none of its sub-fields (sport_filter/league_filter/query/total_events/total_live/updated) exist.", + "evidence": "endpoints.go:1050-1057 (json.NewEncoder encodes map with keys data/pagination/updated_at; Pagination struct only); live: /api/v1/teams?sport=baseball&limit=2 -> {\"data\":[...],\"pagination\":{\"limit\":2,\"offset\":0,\"count\":2,\"total\":330,\"has_more\":true,\"next_offset\":2},\"updated_at\":\"2026-06-27T00:35:59Z\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the Success (200) example to the real {data, pagination, updated_at} shape; replace the 'Meta Object' section with a 'Pagination Object' section ({limit, offset, count, total, has_more, next_offset, next_cursor}); fix JS `const {data, meta}` -> `const {data, pagination}` and the `team.aliases`/`event_count` references; fix Python `result['meta']['count']` -> `result['pagination']['total']`.", + "locale_scope": "all 4 locales (de/es/pt-BR mirror en)", + "unit": "teams" + }, + { + "title": "Documented `q` alias search does not exist — q only substring-matches the team name", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Callout (lines 27-29): 'The `q` parameter searches both the team `name` and `aliases` fields. For example, `q=man city` will match \"Manchester City\" via its alias.' Use-case section repeats q=man%20city returning Manchester City (lines 197-207).", + "reality": "Handler filters with strings.Contains(strings.ToLower(ta.key.Name), qFilter) — it matches ONLY the canonical name, never aliases (there is no aliases field in the accumulator or result struct). Live: q=man%20city returns data:[] / total 0 (Manchester City NOT matched).", + "evidence": "endpoints.go:1000 (only ta.key.Name is searched); live: /api/v1/teams?q=man%20city -> {\"data\":[],\"pagination\":{...\"total\":0...}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the 'searches both name and aliases' claim and the man-city alias example; state q is a case-insensitive substring match against the team display name only.", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "Response has no `aliases` field — entire alias-mapping use case is fictional", + "severity": "HIGH", + "category": "field-missing", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Schema row `aliases | array | Alternative names used by different sportsbooks` (line 165); JS example reads team.aliases.length/join (60-62); Python reads team['aliases'] (79-80); 'Team Name Mapping' use case shows aliases:[\"Man City\",...] (205); Success example includes aliases:[\"Arsenal Fc\"]/[\"Alabama\",...] (109,129).", + "reality": "TeamResult struct has no Aliases field; the response never contains `aliases`. Live baseball rows have no aliases key. The JS/Python sample code will throw (reading .length / iterating undefined).", + "evidence": "endpoints.go:982-995 (TeamResult: Name/NumericalID/Abbreviation/Logo/City/Mascot/Conference/Division/Sport/League/EventCount — no Aliases); live: /api/v1/teams?sport=baseball&limit=2 -> rows contain no `aliases` key", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `aliases` schema row, the aliases use-case section, and all aliases references from JS/Python examples (or remove the field entirely if it will not be implemented).", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "Response field is singular `league` (string), not `leagues` (array)", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Schema row `leagues | array | Leagues this team appears in (e.g. [\"NBA\",\"NCAAB\"])` (line 164); Success example shows leagues:[...] arrays (103-108, 126-128).", + "reality": "Handler returns a single `league` string field (the adapter shard league). There is no `leagues` array. Live: each row has \"league\":\"mlb\".", + "evidence": "endpoints.go:993 (League string `json:\"league\"`); live -> rows show \"league\":\"mlb\" (singular), no \"leagues\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the `leagues` array row with a singular `league` (string) row; fix the Success example accordingly. Note teams appearing in multiple leagues are returned as separate rows keyed by name|sport|league, not collapsed into a leagues array.", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "Response has no `id` field — only `name` identifies a team", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Schema row `id | string | Unique team identifier (slug format, e.g. alabama-crimson-tide)` (line 154); Success examples include id:\"arsenal\"/\"alabama-crimson-tide\" (93,116); Python prints team['id'] (80); 'Always present' claim for slug id (line 174).", + "reality": "TeamResult has no ID field; the response never contains `id`. The slug is used internally for atlas fallback (teamNameToSlug) but is not serialized. Python team['id'] raises KeyError.", + "evidence": "endpoints.go:982-995 (no ID field in TeamResult — contrast TeamRef at pkg/runner/types.go:29 which HAS id but is a different nested struct); live: /api/v1/teams?sport=baseball&limit=2 -> rows have no `id` key", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `id` schema row and the Python team['id'] usage, OR (if intended) note that the slug id is exposed only on nested home/away TeamRef blocks in odds rows, not on /teams rows.", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "Response has no `live_count` field", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Schema row `live_count | integer | Number of currently live events involving this team` (line 167); Success example shows live_count:0 on every row (113,135); meta also fabricates total_live (143,188).", + "reality": "TeamResult has no LiveCount field and the accumulator never tracks live events (only an events set for event_count). Response never contains `live_count`.", + "evidence": "endpoints.go:982-995 (no LiveCount); endpoints.go:1014 (only EventCount=len(ta.events)); live -> rows have no `live_count` key", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `live_count` schema row and live_count from the Success example.", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "logo CDN host is cdn.sharpapi.io, not cdn.opticodds.com", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Success example logo values use host cdn.opticodds.com (lines 97, 120: https://cdn.opticodds.com/team-logos/...).", + "reality": "Live logo URLs use host cdn.sharpapi.io with path /teams/{sport}/{id}.png, e.g. https://cdn.sharpapi.io/teams/baseball/8.png. (Doc schema text at line 158 correctly says 'Treat the host as opaque', but the concrete examples expose a wrong/competitor host.)", + "evidence": "live: /api/v1/teams?sport=baseball&limit=2 -> \"logo\":\"https://cdn.sharpapi.io/teams/baseball/8.png\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the example logo URLs to the cdn.sharpapi.io/teams/{sport}/{id}.png form (and avoid surfacing the OpticOdds host anywhere in customer docs).", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "`limit` and `offset` pagination params are undocumented (response is paginated)", + "severity": "LOW", + "category": "pagination", + "page": "content/en/api-reference/teams.mdx", + "docs_claim": "Query Parameters table (lines 21-26) lists only sport, league, q. No pagination params documented, yet the response includes a pagination block with has_more/next_offset implying client paging.", + "reality": "Handler accepts limit (default 50, max 500 via filters.Opts MaxLimit 500) and offset, and returns a Pagination block clients must page through. Over-cap limit emits a cap warning header. These controls are undocumented.", + "evidence": "endpoints.go:792 (Defaults{DefaultLimit:50, MaxLimit:500}); endpoints.go:811-812 (limit/offset from res.Filters); live: limit=2 honored, total=330, has_more=true, next_offset=2", + "current_status": "still-present", + "confidence": "high", + "fix": "Add `limit` (default 50, max 500) and `offset` rows to the Query Parameters table and document the pagination block (has_more/next_offset) for iterating the full result set.", + "locale_scope": "all 4 locales", + "unit": "teams" + }, + { + "title": "Documented splits sources (DraftKings/Circa) never appear in responses; real sources are 'consensus' and 'betmgm'", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/splits.mdx", + "docs_claim": "Lines 9, 30-33, 102: 'splits ... from DraftKings and Circa Sports'; Data Sources table lists DraftKings + Circa; Response Fields says `sportsbook` is 'Source sportsbook for the splits data (`draftkings` or `circa`)'. Examples (lines 143, 41) use `sportsbook=draftkings` / `draftkings,circa`.", + "reality": "/splits reads splitsCache which is run through collapseSplitsToConsensus(): when DK+Circa per-event payloads are byte-for-byte identical (production sampling found always true, cache.go:1438-1444) they collapse to one row sportsbook=\"consensus\" (splits_consensus.go:79). A third source, betmgm, is also emitted. Live /splits?limit=200 returns only {consensus:19, betmgm:24}; NO row is ever sportsbook=draftkings or =circa. Per-book draftkings/circa survive only in /splits/history (book field).", + "evidence": "live: /api/v1/splits?limit=200 -> book distribution {'consensus':19,'betmgm':24}, total 43; /api/v1/splits?sportsbook=draftkings -> count 0; /api/v1/splits?sportsbook=circa -> count 0. Code: splits_consensus.go:79; cache.go:1445.", + "current_status": "new", + "confidence": "high", + "fix": "Document that /splits returns sportsbook=\"consensus\" (collapsed DK+Circa identical splits) plus betmgm. Update the Data Sources table and the `sportsbook` field description. Note per-book draftkings/circa values are only exposed via /splits/history (`book` field).", + "locale_scope": "en + replicates to de/es/pt-BR (mirrored content)", + "unit": "splits" + }, + { + "title": "Example queries use sportsbook=draftkings / sportsbook=circa, which return zero results on /splits", + "severity": "HIGH", + "category": "example-breaks", + "page": "content/en/api-reference/splits.mdx", + "docs_claim": "Line 41 param-table example `draftkings,circa`; line 143 'DraftKings Only' tab: `curl \".../api/v1/splits?sportsbook=draftkings&sport=basketball\"`.", + "reality": "Copy-pasting the documented `sportsbook=draftkings` (or circa) call against /splits returns an empty data array (count 0) because no /splits row is tagged draftkings/circa post-consensus-collapse. The documented call yields nothing. (The /splits/history tab at line 228 still works because history `book` retains draftkings/circa.)", + "evidence": "live: /api/v1/splits?sportsbook=draftkings&limit=3 -> count 0, total 0; /api/v1/splits?sportsbook=circa -> count 0.", + "current_status": "new", + "confidence": "high", + "fix": "Change the /splits example tabs to `sportsbook=consensus` or `sportsbook=betmgm`. Keep `sportsbook=draftkings` only on /splits/history examples.", + "locale_scope": "en + replicates to de/es/pt-BR", + "unit": "splits" + }, + { + "title": "Filtering /splits by the actual returned value sportsbook=consensus is rejected with invalid_filter", + "severity": "MEDIUM", + "category": "param-noop", + "page": "content/en/api-reference/splits.mdx", + "docs_claim": "Line 41: '`sportsbook` | string | Filter by splits source.' implies you can filter by whatever source value the endpoint returns.", + "reality": "The dominant returned `sportsbook` value is `consensus`, but `consensus` is not in sportsbookConfig/bookIDNormalize, so the filters registry (filters_registry.go:65 IsValidSportsbook) rejects it with HTTP 400 invalid_filter. Customers cannot filter to consensus rows by their own returned value. `betmgm` IS a valid filter; consensus is not.", + "evidence": "live: /api/v1/splits?sportsbook=consensus&limit=3 -> {\"error\":{\"code\":\"invalid_filter\",\"details\":{\"fields\":{\"sportsbook\":[\"consensus\"]}}}}. Code: filters_registry.go:65-70.", + "current_status": "new", + "confidence": "high", + "fix": "Primary fix is code-side (accept 'consensus' as a valid /splits sportsbook filter). For docs: until fixed, do not show sportsbook=consensus as a filterable example, and warn that consensus rows cannot be filtered via the sportsbook param.", + "locale_scope": "en + replicates to de/es/pt-BR", + "unit": "splits" + }, + { + "title": "Response Fields table overstates field presence; betmgm rows lack spread/ML lines and most handle_pct are null", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/splits.mdx", + "docs_claim": "Response Fields table (lines 105-115) presents spread.away_odds/home_odds, spread/total/moneyline handle_pct + bets_pct, and moneyline.away_odds/home_odds as standard fields, implying every row carries them.", + "reality": "For betmgm rows (~half of live rows), spread carries only {bets_pct,handle_pct} with NO away_odds/home_odds line keys, and handle_pct is all null (betmgm publishes ticket% only). Moneyline likewise has no away_odds/home_odds and null handle. Consensus rows have the full shape; betmgm rows are sparse. The doc gives no indication line/handle fields can be absent or null by source.", + "evidence": "live: /api/v1/splits?sportsbook=betmgm&limit=1 -> spread keys ['bets_pct','handle_pct'] (no away_odds/home_odds); ml keys ['bets_pct','handle_pct']; handle_pct all null, only some bets_pct populated. Code: betmgm payload flows through recomputeSplitsCache unchanged (cache.go:1404-1435).", + "current_status": "new", + "confidence": "high", + "fix": "Add a note that field availability is source-dependent: consensus rows carry full spread/ML lines + handle% + bets%; betmgm rows carry bets_pct only (no lines, handle_pct null). Mark line and handle fields as nullable/source-dependent.", + "locale_scope": "en + replicates to de/es/pt-BR", + "unit": "splits" + }, + { + "title": "Per-Selection Bet% (BetMGM) section omits that BetMGM is now also a game-level /splits source", + "severity": "LOW", + "category": "conceptual", + "page": "content/en/api-reference/splits.mdx", + "docs_claim": "Lines 128-130: 'Per-Selection Bet % (BetMGM) ... In addition to the game-level splits from this endpoint, BetMGM odds items in the /odds response include an inline public_bet_pct field.' Frames BetMGM bet% as living only on /odds.", + "reality": "BetMGM is now also a game-level /splits source (sportsbook=betmgm rows carrying bets_pct). The 'In addition to the game-level splits from this endpoint' framing is misleading because BetMGM IS one of the game-level splits sources on this endpoint. (The /odds public_bet_pct claim itself is out of scope here and not re-verified.)", + "evidence": "live: /api/v1/splits?limit=200 includes 24 sportsbook=betmgm rows; /api/v1/splits?sportsbook=betmgm -> count >0. Valkey keys: splits:betmgm:baseball, splits:betmgm:football.", + "current_status": "new", + "confidence": "high", + "fix": "Update the BetMGM section to note BetMGM appears both as a game-level /splits source (bets% only) and inline on /odds via public_bet_pct; clarify the relationship.", + "locale_scope": "en + replicates to de/es/pt-BR", + "unit": "splits" + }, + { + "title": "Example event_id values are stale/seasonal and return no data", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/api-reference/splits.mdx", + "docs_claim": "Lines 123-125, 149, 222, 228 use `nba_thunder_timberwolves_2026-03-15` (NBA mid-March); line 53 sample uses `mlb_guardians_orioles_2026-04-16`.", + "reality": "Stale dated examples; the NBA March event returns zero rows live. Splits are real-time prematch-window data, so any hardcoded dated event_id eventually returns empty. Degrades gracefully (valid empty envelope) but copy-pasted examples show no data.", + "evidence": "live: /api/v1/splits?event_id=nba_thunder_timberwolves_2026-03-15 -> count 0.", + "current_status": "still-present", + "confidence": "medium", + "fix": "Note that event_id examples are illustrative and only resolve during an active prematch window; prefer a current in-season sport (e.g. mlb_*) for the live-ish example.", + "locale_scope": "en + replicates to de/es/pt-BR", + "unit": "splits" + }, + { + "title": "test", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "x", + "reality": "y", + "evidence": "z", + "current_status": "still-present", + "confidence": "high", + "fix": "f", + "unit": "gamestate" + }, + { + "title": "Document the REQUIRED future_id parameter on GET /api/v1/futures/odds", + "severity": "HIGH", + "category": "field-missing", + "page": "api-reference/enterprise.mdx", + "docs_claim": "GET /api/v1/futures/odds is shown with a bare example (`GET /api/v1/futures/odds`) and a Query Parameters table listing only sport, league, sportsbook, market, limit, offset — `future_id` is never mentioned.", + "reality": "The handler hard-requires `future_id`. Without it the endpoint returns HTTP 400 {\"error\":{\"code\":\"validation_error\",\"message\":\"Missing required parameter: future_id\"}}. Any reader copy-pasting the documented bare call gets a 400.", + "evidence": "futures.go:167-178 (futureID := q.Get(\"future_id\"); if \"\" → 400 ValidationError). live: GET /api/v1/futures/odds?limit=2 -> {\"error\":{\"code\":\"validation_error\",\"message\":\"Missing required parameter: future_id\"}}", + "current_status": "new", + "confidence": "high", + "fix": "Add `future_id` (string, REQUIRED) as the first row of the /futures/odds parameter table; note that it is the market_id returned by GET /api/v1/futures (e.g. `championship_winner`, `outright`). Update the example to `GET /api/v1/futures/odds?future_id=championship_winner`. The OpenAPI spec (public/openapi.json:3092-3101) already marks it required:true — only the mdx page omits it.", + "locale_scope": "All 4 locales (en, de, es, pt-BR mirror this exact table).", + "unit": "enterprise-futures" + }, + { + "title": "Remove the no-op `market` parameter from GET /api/v1/futures/odds", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/enterprise.mdx", + "docs_claim": "/futures/odds table line 50: `market | string | all | Filter by market type`.", + "reality": "The handler never reads a `market` query param. It reads future_id, sportsbook, sport, league, limit, offset only. Passing `market=...` has zero effect (silently ignored).", + "evidence": "futures.go:165-190 (param parsing in futuresOddsHandler reads future_id, sportsbook via getSportsbookParam, sport, league, limit, offset — no q.Get(\"market\"))", + "current_status": "still-present", + "confidence": "high", + "fix": "Delete the `market` row from the /futures/odds parameter table. To filter by market you pass `future_id` (the market_id). Mirror across all 4 locales.", + "unit": "enterprise-futures" + }, + { + "title": "Document the real /futures/odds filters: sportsbook, sport, league", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/enterprise.mdx", + "docs_claim": "/futures/odds table lists sport, league, sportsbook, market, limit, offset — but `sportsbook` is the only correct filter; sport/league are listed yet the page presents them as catalog-shaped, and the real handler also accepts these.", + "reality": "The handler DOES support `sportsbook` (CSV), `sport`, and `league` filters plus `limit`/`offset` pagination. sport and league are correctly listed, sportsbook is correctly listed; the defect is they sit next to the bogus `market` row and the missing `future_id`, making the param set as-documented unusable as a whole.", + "evidence": "futures.go:180-190 (sbookFilter := parseCSVFilter(getSportsbookParam(q)); sportFilter q.Get(\"sport\"); leagueFilter q.Get(\"league\"); limit/offset parsed, clamped to max 200/default 100)", + "current_status": "not-a-bug", + "confidence": "high", + "fix": "sportsbook/sport/league/limit/offset rows are accurate for /futures/odds (keep them). Net change for this endpoint is only: add future_id (required), drop market. Note limit clamps to max 200, default 100 when out of range (futures.go:185-187) — docs say default 50, see separate finding.", + "unit": "enterprise-futures" + }, + { + "title": "GET /api/v1/futures catalog: documented sportsbook, limit, offset params are all no-ops", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/enterprise.mdx", + "docs_claim": "/futures Query Parameters table (lines 27-33) lists sportsbook, limit (default 50, max 200), offset as supported.", + "reality": "futuresCatalogHandler reads ONLY sport, league, future_type. It does no pagination at all — no limit/offset slicing — and has no sportsbook filter. The response returns the full merged catalog every time. Documented sportsbook/limit/offset are silently ignored.", + "evidence": "futures.go:31-132 (futuresCatalogHandler parses sportFilter, leagueFilter, futureTypeFilter only; loops merged entries, applies those 3 filters, sorts, returns all — no offset/limit/sportsbook). live: GET /api/v1/futures returns meta.count=43 with no pagination object.", + "current_status": "still-present", + "confidence": "high", + "fix": "Drop the sportsbook, limit, and offset rows from the /futures catalog table (they don't exist). The catalog is returned in full, sorted by selection_count desc. Mirror across all 4 locales.", + "unit": "enterprise-futures" + }, + { + "title": "GET /api/v1/futures catalog: missing the real `future_type` filter param", + "severity": "LOW", + "category": "field-missing", + "page": "api-reference/enterprise.mdx", + "docs_claim": "/futures Query Parameters table lists sport, league, sportsbook, limit, offset — no `future_type`.", + "reality": "The handler supports `future_type` (uppercased; enum TEAM|PLAYER|OVER_UNDER|YES_NO) as a real filter. It is the one filter besides sport/league that actually works on the catalog endpoint.", + "evidence": "futures.go:35 (futureTypeFilter := strings.ToUpper(...q.Get(\"future_type\"))) and 107-109 (filter applied). OpenAPI public/openapi.json:3021-3034 documents future_type with the enum — only the mdx omits it.", + "current_status": "new", + "confidence": "high", + "fix": "Add `future_type` (string, enum: TEAM | PLAYER | OVER_UNDER | YES_NO) to the /futures catalog parameter table. Mirror across all 4 locales.", + "unit": "enterprise-futures" + }, + { + "title": "Document the response envelopes for both futures endpoints", + "severity": "MEDIUM", + "category": "envelope", + "page": "api-reference/enterprise.mdx", + "docs_claim": "The page shows no response shape or sample body for either /futures or /futures/odds — only request signatures and param tables.", + "reality": "Both endpoints return WRAPPED envelopes (the group-3 {success,data,meta} family, not the flat {data,updated_at} family). /futures -> {success:true, data:[FutureMarket], meta:{count, updated_at}}. /futures/odds -> {success:true, data:[FutureOdds], pagination:{limit,offset,has_more,next_offset}, meta:{future_id, books_count, selections_count, count, total, updated_at}}.", + "evidence": "futures.go:124-131 (catalog: success/data/meta{count,updated_at}); futures.go:352-366 (odds: success/data/pagination/meta{future_id,books_count,selections_count,count,total,updated_at}). live: /futures top keys [data,meta,success], meta={count:43,updated_at}; /futures/odds?future_id=outright top keys [data,meta,pagination,success], pagination={limit,offset,count,has_more,next_offset}, meta={books_count,count,future_id,selections_count,total,updated_at}", + "current_status": "still-present", + "confidence": "high", + "fix": "Add response examples. Note both are wrapped (carry top-level `success:true` plus `meta`) — unlike /odds (flat). FutureMarket fields: market_id, display_name, sport, league, future_type, selection_count, books_available. FutureOdds fields: selection, sportsbook, odds_american, odds_decimal, probability, line(nullable), future_type, future_market_id, future_market_name, sport, league, event_start_time. (Handler does NOT emit a `timestamp` field although OpenAPI FutureOdds lists one — see separate finding.)", + "unit": "enterprise-futures" + }, + { + "title": "Frontmatter book-count claim '43 supported sportsbooks' is stale/wrong", + "severity": "LOW", + "category": "conceptual", + "page": "api-reference/enterprise.mdx", + "docs_claim": "Frontmatter description (line 2): 'futures market catalog and odds across all 43 supported sportsbooks.'", + "reality": "Live /sportsbooks returns 34 books. defaultBookPriority lists 35 entries (and includes the retired 'Bet365 UK'). There is no '43' anywhere in the book registry. This is the same inconsistent-book-count class flagged as L11 in the prior audit (which saw '43' here vs '32' elsewhere).", + "evidence": "tiers.go:159-167 (defaultBookPriority = 35 entries incl. retired 'Bet365 UK'). live: GET /api/v1/sportsbooks -> count 34. enterprise.mdx:2 (de/es frontmatter also says 43).", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace '43' with the canonical platform book figure used elsewhere in docs (pick one number and apply uniformly; live is 34). The futures catalog is also not constrained to a fixed book set — reword to 'across supported sportsbooks' to avoid pinning a number. Mirror across all 4 locales (de line 2 also says 43).", + "unit": "enterprise-futures" + }, + { + "title": "/futures/odds default limit documented as 50 but handler defaults to 100", + "severity": "LOW", + "category": "pagination", + "page": "api-reference/enterprise.mdx", + "docs_claim": "/futures/odds table line 51: `limit | integer | 50 | Results per page (max 200)`.", + "reality": "When limit is missing, <=0, or >200, the handler sets it to 100 (not 50). Max 200 is correct.", + "evidence": "futures.go:185-187 (if limit <= 0 || limit > 200 { limit = 100 })", + "current_status": "new", + "confidence": "high", + "fix": "Change the documented default for /futures/odds limit from 50 to 100. Max 200 is correct. (Note: limit/offset are no-ops on the /futures catalog endpoint — covered by a separate finding.) Mirror across all 4 locales.", + "unit": "enterprise-futures" + }, + { + "title": "OpenAPI spec marks /futures as a public endpoint; it is Enterprise-gated", + "severity": "MEDIUM", + "category": "openapi-drift", + "page": "public/openapi.json", + "docs_claim": "openapi.json /futures: security:[{},{ApiKeyHeader:[]}] and description 'Public endpoint.' (lines 2998-3003, 2994). /futures/odds description says only 'Requires authentication' with no tier note (line 3087).", + "reality": "Both routes are registered with apiKey=true AND tierGateMiddleware('futures'), which resolves to Enterprise only (the 'futures' feature exists only on the enterprise tier). A non-enterprise (or unauthenticated) caller gets 401/403 tier_restricted, never a 200. /futures is NOT public.", + "evidence": "routes.go:83-84 (apiKeyRoute(..., true, ..., \"futures\", true, futuresCatalogHandler, rateLimitMiddleware, futuresGate)); routes.go:54 (futuresGate := tierGateMiddleware(\"futures\")); tiers.go:61 (only enterprise Features list contains \"futures\")", + "current_status": "still-present", + "confidence": "high", + "fix": "In openapi.json remove the empty `{}` security entry and the 'Public endpoint.' wording from /futures; mark both /futures and /futures/odds as requiring ApiKeyHeader + Enterprise tier (add a tier note / 403 response). The mdx page itself correctly states Enterprise-only — this drift is in the machine spec.", + "unit": "enterprise-futures" + }, + { + "title": "OpenAPI FutureOdds schema lists a `timestamp` field the handler never emits", + "severity": "LOW", + "category": "openapi-drift", + "page": "public/openapi.json", + "docs_claim": "openapi.json FutureOdds schema marks `timestamp` (date-time) as REQUIRED and the /futures/odds example includes \"timestamp\":\"2026-03-09T14:00:00Z\" (lines 3169, ~5688-5740).", + "reality": "FuturesOddsEntry has no Timestamp field; the handler builds the response struct with selection..event_start_time only. No `timestamp` is serialized.", + "evidence": "futures.go:142-155 (FuturesOddsEntry struct ends at EventStartTime — no Timestamp/json:\"timestamp\"); futures.go:313-326 (response construction omits any timestamp)", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove `timestamp` from the FutureOdds schema (both the properties block and the required list) and from the /futures/odds example in openapi.json. event_start_time is the only time field actually returned.", + "unit": "enterprise-futures" + }, + { + "title": "historical-odds-closing declares Sharp tier; endpoint is history-gated = Enterprise-only", + "severity": "HIGH", + "category": "tier", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": "frontmatter:3 'Sharp tier required.'; body :15-17 'Sharp tier or higher required. Pro and lower tiers receive a 403 tier_restricted error. This endpoint is gated by the closing_lines feature.'; :25 'Sharp tier or higher required (closing_lines feature).'", + "reality": "Route /api/v1/historical/odds/closing is gated by the 'history' feature (routes.go:87), and 'history' appears ONLY in the enterprise tier feature list (tiers.go:61). requiredTierForFeature('history') resolves to 'enterprise'. Any tier below Enterprise (incl. Sharp) gets 403 before reaching the handler.", + "evidence": "routes.go:87 (apiKeyRoute ... \"history\" ... OddsClosingHandlerBacked); tiers.go:61 (enterprise Features incl \"history\"); tiers.go:301-309 requiredTierForFeature; live: GET /historical/odds/closing with enterprise key -> 200 meta.tier_window_days=unlimited", + "current_status": "still-present", + "confidence": "high", + "fix": "Change frontmatter + body to 'Enterprise tier required', gated by the 'history' feature. Remove all references to a 'closing_lines' feature and 'Sharp tier'. (Prior audit H15/H16; not fixed by commit 9a9af03.)", + "locale_scope": "EN + de/es/pt-BR all replicate (verified de:16,25 / es:16,25 / pt-BR:16,25)", + "unit": "historical" + }, + { + "title": "closing_lines feature does not exist (only closing_line singular, a different Pro+ endpoint)", + "severity": "HIGH", + "category": "tier", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":16,:25 'This endpoint is gated by the closing_lines feature.' and 403 example :223 message \"The 'closing_lines' feature requires Sharp or higher.\"", + "reality": "No feature named 'closing_lines' exists anywhere in tiers.go. The only similarly-named feature is 'closing_line' (singular) in pro/sharp/enterprise (tiers.go:43,54,61) which gates the DIFFERENT /api/v1/odds/closing (Pro+) endpoint. /historical/odds/closing is gated by 'history' (Enterprise).", + "evidence": "tiers.go:43,54,61 (closing_line singular only); grep 'closing_lines' across *.go returns only ClickHouse table/column references (metrics.go, closing.go), never a tier feature; routes.go:87 gate=history", + "current_status": "still-present", + "confidence": "high", + "fix": "Delete the non-existent 'closing_lines' feature reference. Distinguish clearly from Pro+ /odds/closing (closing_line). Gate fact is 'history' (Enterprise).", + "locale_scope": "EN + all 3 locales (de/es/pt-BR :16,:25)", + "unit": "historical" + }, + { + "title": "closing 403 wrong-tier example body is wrong on every field", + "severity": "HIGH", + "category": "errcode", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":218-228 403 example: {code:tier_restricted, message:\"The 'closing_lines' feature requires Sharp or higher.\", required_tier:\"sharp\", docs:\"https://docs.sharpapi.io/en/pricing\"} (no tier field).", + "reality": "tierGateMiddleware('history') emits: message=\"This endpoint requires Enterprise tier or higher\", required_tier=\"enterprise\", docs=\"https://sharpapi.io/pricing\", AND includes a tier field (the caller's current tier). Message wording, required_tier, docs URL, and presence of the tier field all differ.", + "evidence": "tiers.go:329-343 (tierGateMiddleware body); tiers.go:336 fmt 'This endpoint requires %s tier or higher' with capitalize(requiredTier); tiers.go:449-454 capitalize -> 'Enterprise'; tiers.go:338 tier field; tiers.go:337 docs=https://sharpapi.io/pricing", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the 403 example with the real history-gate body: {code:tier_restricted, message:'This endpoint requires Enterprise tier or higher', docs:'https://sharpapi.io/pricing', tier:'', required_tier:'enterprise'}. (Matches the CLV page's correct 403 example.)", + "locale_scope": "EN + all 3 locales (de/es/pt-BR :194 onward block replicates)", + "unit": "historical" + }, + { + "title": "closing meta.source documented as valkey:closing_line; reality is clickhouse", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":194 response example \"source\": \"valkey:closing_line\"; :296 field table 'source | Always \"valkey:closing_line\" (Phase 1 backend)'", + "reality": "Both the backed handler and the legacy stub set meta.source to \"clickhouse\". No code path emits \"valkey:closing_line\".", + "evidence": "historical.go:336 (OddsClosingHandlerBacked meta source 'clickhouse'); historical.go:409 (stub same); live: GET /historical/odds/closing -> meta.source='clickhouse'", + "current_status": "still-present", + "confidence": "high", + "fix": "Change source to 'clickhouse' in the example and field table; drop the 'Phase 1 / valkey' framing.", + "locale_scope": "EN + all 3 locales (verified de/es/pt-BR :194 source:valkey:closing_line)", + "unit": "historical" + }, + { + "title": "closing meta.tier_window_days documented as 30; reality unlimited for the only eligible tier", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":195 example \"tier_window_days\": 30; :297 field table 'Maximum lookback days for your tier (Sharp = 30)'; :19-21 callout 'Valkey retains 30 days... Requesting a date older than 30 days will return an empty result'; :38-39 'Sharp tier allows up to 30 days of lookback'", + "reality": "Only Enterprise can reach this endpoint (history gate), and DaysLimitForTier('enterprise') = -1, serialized as the string \"unlimited\". The 30-day window is dead language carried over from a Sharp/Valkey Phase-1 design that no longer applies.", + "evidence": "historical.go:86-99 DaysLimitForTier (enterprise -1); historical.go:316-321 (-1 -> 'unlimited'); live: GET /historical/odds/closing -> meta.tier_window_days='unlimited'", + "current_status": "still-present", + "confidence": "high", + "fix": "Set tier_window_days to 'unlimited' in example + field table; remove the 30-day Valkey-retention callout and the 'Date Window' section's 30-day claim.", + "locale_scope": "EN + all 3 locales (de/es/pt-BR :195 tier_window_days:30)", + "unit": "historical" + }, + { + "title": "closing data.events[].books is an array in docs; reality is an object keyed by book (no 'book' field)", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":124-187 example shows \"books\": [ {\"book\":\"pinnacle\", \"captured_at\":..., \"lines\":[...]}, {\"book\":\"draftkings\",...} ] (an array, each element carrying a 'book' field and a 'lines' array). Field table :270-274 lists a 'book' field and a 'lines' array on the Book object.", + "reality": "ClosingSnapshot.Books is map[string]BookClosing (object keyed by book name). There is NO 'book' field on the entry (the key IS the book). The per-book array of prices is named 'odds', NOT 'lines'. BookClosing also has captured_at, seconds_before_kickoff, capture_trigger, is_final.", + "evidence": "closing.go:169 (Books map[string]BookClosing json:\"books\"); closing.go:152-158 BookClosing (Odds []ClosingOdd json:\"odds\"; no Book field; IsFinal/CapturedAt/SecondsBeforeKickoff/CaptureTrigger)", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite example to books as an object: \"books\": { \"pinnacle\": {\"captured_at\":..., \"odds\":[...]}, ... }. Rename 'lines' to 'odds' everywhere. Replace the 'Book object' field table: drop 'book', rename 'lines'->'odds', add seconds_before_kickoff, capture_trigger, is_final.", + "locale_scope": "EN + all 3 locales replicate the same example structure", + "unit": "historical" + }, + { + "title": "closing example code iterates books as array and reads .book — throws / errors when run", + "severity": "CRITICAL", + "category": "example-breaks", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": "JS :68-74 'for (const event of data.events) { for (const book of event.books) { const ml = book.lines.find(...); ... book.book ... } }'. Python :93-99 'for event in data[\"events\"]: for book in event[\"books\"]: for line in book[\"lines\"]: ... book[\"book\"] ...'", + "reality": "event.books is a JSON object (map), not an array. JS 'for...of event.books' throws TypeError: event.books is not iterable. Python 'for book in event[\"books\"]' iterates over string keys, then book['lines']/book['book'] raises TypeError: string indices must be integers. Also book.lines should be book.odds, and there is no .book field (the key is the book name).", + "evidence": "closing.go:169 Books map[string]BookClosing; closing.go:153 Odds []ClosingOdd json:\"odds\" (not 'lines'); JS for...of over object literally throws", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite examples to iterate the books object: JS 'for (const [bookName, book] of Object.entries(event.books)) { const ml = book.odds.find(l => l.market_type===\"moneyline\"); ... bookName ... }'; Python 'for book_name, book in event[\"books\"].items(): for line in book[\"odds\"]: ...'. Use book.odds not book.lines; use the map key for the book name.", + "locale_scope": "EN + all 3 locales (same code blocks)", + "unit": "historical" + }, + { + "title": "closing 'Closing line entry' field table lists non-existent 'timestamp' field and omits many real fields", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":290 lists 'timestamp | string | ISO 8601 timestamp of the odds record used for capture' as a closing-line-entry field. Table only lists market_type, selection, selection_type, line, player_name, stat_category, odds_american, odds_decimal, implied_probability, no_vig_probability, timestamp.", + "reality": "ClosingOdd has NO 'timestamp' field. Real fields the doc omits: sportsbook, market_id, selection_id, canonical_key, fair_close_decimal, closing_probability, source_updated_at, max_bet, team_side, market_segment, odds_after_kickoff. The closest real time field is 'source_updated_at' (omitempty).", + "evidence": "closing.go:88-147 ClosingOdd struct json tags (no 'timestamp'; has sportsbook, market_id, selection_id, canonical_key, fair_close_decimal, closing_probability, source_updated_at, max_bet, team_side, market_segment, odds_after_kickoff)", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove 'timestamp' row. Either document the real fields (sportsbook, source_updated_at, odds_after_kickoff, etc.) or note that omitempty fields are absent when unset. At minimum replace 'timestamp' with 'source_updated_at'.", + "locale_scope": "EN + all 3 locales", + "unit": "historical" + }, + { + "title": "closing example shows 'line': null for moneylines; reality omits the field (omitempty)", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": ":134,:145,:171,:182 example moneyline entries show \"line\": null; field table :283 'line | number|null | ... null for moneylines'", + "reality": "Line is *float64 with json:\"line,omitempty\"; a nil line is OMITTED from JSON, not serialized as null. The 'line' key will be absent on moneyline rows, not present-with-null.", + "evidence": "closing.go:96 (Line *float64 json:\"line,omitempty\")", + "current_status": "still-present", + "confidence": "high", + "fix": "Drop 'line': null from moneyline example entries (field is absent); change field-table type note from 'number|null' to 'number (omitted for moneylines)'.", + "locale_scope": "EN + all 3 locales", + "unit": "historical" + }, + { + "title": "CLV 'History window by tier' table implies sub-Enterprise tiers can call the endpoint", + "severity": "MEDIUM", + "category": "tier", + "page": "content/en/api-reference/historical-clv.mdx", + "docs_claim": ":43-53 'History window by tier' table: Free 7d, Hobby 30d, Pro 90d, Sharp 365d, Enterprise Unlimited — presented as the lookback available per tier when calling this endpoint.", + "reality": "The route is gated by the 'history' feature = Enterprise-only. Every tier below Enterprise gets 403 tier_restricted before any date-window clamp applies. Only the Enterprise (unlimited) row is reachable. The DaysLimitForTier values are real (used internally for window clamping) but irrelevant here since non-Enterprise callers never reach the handler.", + "evidence": "routes.go:91 (CLV gated 'history'); tiers.go:61 (history=enterprise only); historical.go:86-99 DaysLimitForTier; live: enterprise key -> tier_window_days='unlimited'", + "current_status": "still-present", + "confidence": "high", + "fix": "Either remove the per-tier window table (only Enterprise/unlimited applies) or add a clear note that sub-Enterprise tiers receive 403 and the table reflects internal clamp values not endpoint access. (Prior audit M19.)", + "locale_scope": "EN + likely de/es/pt-BR (CLV body Enterprise tier already correct)", + "unit": "historical" + }, + { + "title": "CLV/closing pages omit the 503 not_ready response when ClickHouse read conn is down", + "severity": "LOW", + "category": "errcode", + "page": "content/en/api-reference/historical-clv.mdx", + "docs_claim": "Error Responses section :186-209 lists only 403 tier_restricted and 400 validation_error.", + "reality": "CLVHandler returns 503 not_ready ('clickhouse read connection is not available') when chReadConn is nil, and 502 upstream_error on a failed CH query. Closing handler returns 500 upstream_error on query failure. None documented.", + "evidence": "historical.go:731-735 (CLV 503 NotReady); historical.go:744-748 (CLV 502 UpstreamError); historical.go:304-306 (closing 500 UpstreamError)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a brief note that transient backend unavailability returns 503 not_ready / 5xx upstream_error. Low priority (not normal-operation).", + "locale_scope": "EN + all 3 locales (error sections mirror)", + "unit": "historical" + }, + { + "title": "closing data.events can be null (not [] ) when no events match", + "severity": "LOW", + "category": "envelope", + "page": "content/en/api-reference/historical-odds-closing.mdx", + "docs_claim": "Example always shows data.events as a populated array; field table :251 'events | array'.", + "reality": "Live empty result returns data.events = null (the Go []ClosingSnapshot slice serializes nil as JSON null), with total_events:0, total_lines:0. Consumers must guard for null, not just empty array.", + "evidence": "live: GET /historical/odds/closing?date=2026-06-24&sport=basketball&league=nba -> {\"data\":{...,\"events\":null,\"total_events\":0,...}}; historical.go:331 events:snaps (snaps may be nil)", + "current_status": "new", + "confidence": "high", + "fix": "Note that events may be null (not []) when no events match; advise guarding for both. Optional: note total_events/total_lines are the reliable count fields.", + "locale_scope": "EN + all 3 locales", + "unit": "historical" + }, + { + "title": "account/usage response shape is almost entirely fictional", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/account-usage.mdx", + "docs_claim": "Response is {data:{requests:{today,this_week,this_month,limit:{per_minute,remaining,resets_at}}, streams:{active,max}, period:{start,end}}, meta:{updated_at}} — see JSON sample L69-95, field tables L120-150, and JS/Python examples L40-44, L57-60.", + "reality": "Live + handler return {data:{requests_today, rate_limit:{limit, remaining, reset}, active_streams}, updated_at}. There is no `requests` object (no today/this_week/this_month nesting), no `limit.per_minute`/`remaining`/`resets_at` (real fields are rate_limit.limit / .remaining / .reset where reset is a UNIX-SECONDS INTEGER, not an ISO string), no `streams` object (just a flat active_streams int, with NO max field at all), no `period` object, and no `meta` envelope (it is a flat top-level `updated_at`). Every documented field is wrong; the JS/Python example code (data.requests.today, data.requests.limit.per_minute, data.streams.active/max) throws TypeError/KeyError.", + "evidence": "endpoints.go:1609-1620; live: GET /api/v1/account/usage -> {\"data\":{\"active_streams\":0,\"rate_limit\":{\"limit\":10000,\"remaining\":10000,\"reset\":1782520740},\"requests_today\":0},\"updated_at\":\"2026-06-27T...\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the JSON sample, field tables, and code examples to the real flat shape: {data:{requests_today:int, rate_limit:{limit:int, remaining:int, reset:int /*unix seconds*/}, active_streams:int}, updated_at:ISO}. Note reset is a unix-seconds integer (not ISO), there is no this_week/this_month/period/streams.max/meta. Replicate to de/es/pt-BR (pt-BR `this_month` confirmed in prior audit).", + "locale_scope": "Replicates to all 4 locales (de/es/pt-BR translated from same source; prior audit spot-confirmed `this_month` in pt-BR account-usage).", + "unit": "account" + }, + { + "title": "account/usage 401 example uses wrong error code `unauthorized`", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/account-usage.mdx", + "docs_claim": "401 error body shows {error:{code:\"unauthorized\", message:\"Invalid or missing API key\", docs:...}} (L109-118).", + "reality": "Auth middleware emits `missing_api_key` (no key) or `invalid_api_key` (bad key). `unauthorized` is reserved for admin/monitoring Bearer auth and is never returned by this apiKey route. Live no-key/bad-key body uses these codes; docs would mislead a client that branches on error.code.", + "evidence": "auth.go:1136 (MissingAPIKey), auth.go:1179/1250/1339 (InvalidAPIKey); errcodes.go:19,23,31; live: GET /api/v1/account/usage with bad key -> {\"error\":{\"code\":\"invalid_api_key\",...}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the 401 example code to `missing_api_key` (no key supplied) and/or document `invalid_api_key` for a bad key. Same edit on all 4 locales.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "account" + }, + { + "title": "account 401 example uses wrong error code `unauthorized`", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/account.mdx", + "docs_claim": "401 error body shows {error:{code:\"unauthorized\", message:\"Invalid or missing API key\", docs:...}} (L106-115).", + "reality": "Auth middleware emits `missing_api_key` (no key) or `invalid_api_key` (bad key), never `unauthorized` on apiKey routes. `unauthorized` is reserved for admin/monitoring Bearer auth.", + "evidence": "auth.go:1136 (MissingAPIKey), auth.go:1179 (InvalidAPIKey); errcodes.go:19,23,31; live: GET /api/v1/account with bad key -> {\"error\":{\"code\":\"invalid_api_key\",...}}", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the 401 example code to `missing_api_key` / `invalid_api_key`. Same edit on all 4 locales.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "account" + }, + { + "title": "account streaming.max_connections example value of 10 is impossible for the shown tier", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/api-reference/account.mdx", + "docs_claim": "Success JSON sample (pro tier) shows streaming:{enabled:true, max_connections:10} (L85-88), and the JS example prints data.rate_limit... while implying 10 stream slots.", + "reality": "defaultMaxConcurrentStreams returns 1 for every paid tier (0 for free); the account handler bumps enterprise to 50 and otherwise uses the Unkey `X-Max-Streams` override or defaults addon-streaming to 1. No tier (and certainly not pro) ever defaults to 10 — the value is only >1 with an explicit Unkey maxStreams override. The example sets an unattainable expectation (customer reads 10, gets 1, sees 4001 displacement on the 2nd socket).", + "evidence": "tiers.go:137-144 (defaultMaxConcurrentStreams: free 0, else 1); endpoints.go:1533-1538 (addon default 1, enterprise 50); live: /account (enterprise) -> streaming.max_connections=50, not 10", + "current_status": "new", + "confidence": "high", + "fix": "Change the example to max_connections:1 (the real paid-tier default) and add a note that the per-key default is 1 with newer-wins displacement, raisable via Unkey maxStreams metadata / Enterprise. Avoid the misleading 10. All 4 locales.", + "locale_scope": "Replicates to all 4 locales (same JSON sample).", + "unit": "account" + }, + { + "title": "account-usage doc invents a streams.max field that does not exist", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/account-usage.mdx", + "docs_claim": "Documents data.streams.max (\"Maximum concurrent streams allowed\") and uses it in both code examples (data.streams.max, `streams.active >= streams.max`, value shown as 10) — L44, L60, L84, L142-143, L179-183.", + "reality": "The usage handler returns only a flat `active_streams` integer and no max-streams field whatsoever. There is no `streams` object and no max. The 'Check Stream Availability' example (L173-184) reads data.streams.active and data.streams.max — both undefined — so the guard logic is broken (NaN comparison).", + "evidence": "endpoints.go:1617 (only \"active_streams\" written; no max); live: GET /api/v1/account/usage -> data has active_streams but no streams/max key", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove streams.max from the field table, JSON sample, and both examples; expose active_streams as a flat int. If a max is desired, surface it from /account (streaming.max_connections) and cross-link. All 4 locales.", + "locale_scope": "Replicates to all 4 locales.", + "unit": "account" + }, + { + "title": "List response meta documents count/total/pagination/updated_at but real meta is {count, max_keys}", + "severity": "MEDIUM", + "category": "envelope", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "List 200 response shows `meta:{count, total, pagination:{limit,offset,has_more,next_offset}, updated_at}` and NO top-level `success` (lines 91-102).", + "reality": "Real envelope is `{success:true, data:[...], meta:{count, max_keys}}`. There is no `total`, no `pagination`, no `updated_at`, and no `success:true` in the doc sample. List is NOT paginated (Supabase query has no limit/offset).", + "evidence": "keys.go:313-321 (json.NewEncoder writes success:true, data, meta:{count:len, max_keys:maxKeys}); query keys.go:248-253 has no pagination.", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the List 200 sample to `{\"success\":true,\"data\":[...],\"meta\":{\"count\":2,\"max_keys\":15}}`. Drop the fabricated `total`/`pagination`/`updated_at` keys and add `success:true` + `max_keys`.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "id_masked format documented as 'sharpapi_...f456' but real mask is '...' + last 8 chars of key ID", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "`id_masked` examples shown as `sharpapi_...f456` / `sharpapi_...i012` (lines 75,84) and described as 'first and last characters visible' (line 110). JS/Python examples print `key.id_masked` (lines 47,62).", + "reality": "Mask is `\"...\" + last 8 chars of UnkeyKeyID` (no `sharpapi_` prefix, no leading characters visible). For a key id like `key_abc123def456` it yields `...23def456`. If id <= 8 chars the field is the full id unmasked.", + "evidence": "keys.go:293-296 (masked := k.UnkeyKeyID; if len>8 { masked = \"...\"+masked[len-8:] }).", + "current_status": "still-present", + "confidence": "high", + "fix": "Change id_masked examples to the real form (e.g. `...23def456`) and update the description to 'last 8 characters visible, prefixed with ...'. Remove the implied `sharpapi_` prefix and 'first ... characters visible' wording.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Create response documented as 201 with bare {data:{...}}; real is HTTP 200 with {success, data, meta:{warning}}", + "severity": "MEDIUM", + "category": "envelope", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "Section header 'Response (201)' (line 178) and sample `{data:{id,key,name,tier}}` with no `success`/`meta` (lines 180-189).", + "reality": "Status is HTTP 200 (no w.WriteHeader(201) anywhere in createKeyHandler — json.NewEncoder defaults to 200). Body is `{success:true, data:{id,key,name,tier}, meta:{warning:\"This is the only time the key value will be shown...\"}}`.", + "evidence": "keys.go:483-495 (no WriteHeader; encodes success:true,data{id,key,name,tier},meta{warning}). Status default 200 confirmed by absence of any 201 write in 328-496.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change header to 'Response (200)', add `success:true` and `meta:{warning}` to the sample. (The JS/Python examples reading `data.key` are correct since `key` is under `data`.)", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Create says name is Required but it is optional (defaults to 'Key for ')", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "Request Body table marks `name` Required: Yes (lines 129-131).", + "reality": "`name` is optional. Empty/absent name is accepted; Unkey key name defaults to `\"Key for \"+userID` and the Supabase name column is stored NULL. Only constraint is max 100 chars.", + "evidence": "keys.go:344-355 (decode optional body; only rejects len>100), keys.go:395-398 (keyName defaults to 'Key for '+UserID), keys.go:466 (name: nilIfEmpty(body.Name)).", + "current_status": "still-present", + "confidence": "high", + "fix": "Mark `name` Required: No; note it defaults to 'Key for ' when omitted and has a 100-char max.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Delete response envelope wrong: doc shows flat {deleted,key_id,message} but real nests under data and is wrapped", + "severity": "MEDIUM", + "category": "envelope", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "Delete 200 sample is top-level `{deleted:true, key_id, message}` (lines 249-255); JS/Python examples read `result.key_id` (lines 230,242).", + "reality": "Real body is `{success:true, data:{deleted:true, key_id, message}}`. The fields live under `data`, so `result.key_id` is undefined; correct path is `result.data.key_id`.", + "evidence": "keys.go:590-597 (encodes success:true, data:{deleted, key_id, message}).", + "current_status": "still-present", + "confidence": "high", + "fix": "Nest the Delete sample under `{success:true, data:{...}}` and fix both code examples to read `result.data.key_id`.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Rotate response envelope wholesale wrong: documented flat data fields do not exist; real uses new_key/old_key nesting", + "severity": "HIGH", + "category": "envelope", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "Rotate 200 sample `{data:{key_id,name,key,key_preview,status,rotated_at,previous_key_revoked}, meta:{updated_at}}` (lines 341-356); examples read `data.key` (lines 320,333).", + "reality": "Real body is `{success:true, data:{new_key:{id,key,name,tier}, old_key:{id,revoked,expires_at}}, meta:{warning,message}}`. None of `key_id`, `key_preview`, `status`, `rotated_at`, `previous_key_revoked` exist at `data`, and there is no `meta.updated_at`. The new key value is at `data.new_key.key` — so the JS/Python examples reading `data.key` get undefined (copy-paste customer code breaks on a security-critical rotation flow).", + "evidence": "keys.go:817-837 (success:true; data:{new_key:{id,key,name,tier}, old_key:{id,revoked,expires_at}}; meta:{warning,message}).", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the entire Rotate 200 sample with `{success:true, data:{new_key:{id,key,name,tier}, old_key:{id,revoked,expires_at}}, meta:{warning,message}}`. Fix JS/Python examples to read `data.new_key.key`.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Rotate optional gracePeriodHours and name request-body params are undocumented", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "Rotate documents only path param keyId and no request body (lines 287-337).", + "reality": "Rotate accepts an optional JSON body `{gracePeriodHours: number (0-72), name: string}`. With gracePeriodHours>0 the old key stays valid (expires_at set, old_key.revoked=false) instead of being immediately revoked; values <0 or >72 return 400 validation_error. A renaming `name` can be supplied. The doc and Callouts assert the old key is 'revoked the instant rotation completes' unconditionally, which is false when a grace period is requested.", + "evidence": "keys.go:627-644 (parse gracePeriodHours/name, 0-72 bound), keys.go:766-808 (grace branch sets expires_at, revoked=false vs immediate delete).", + "current_status": "new", + "confidence": "high", + "fix": "Add an optional Request Body table for Rotate documenting `gracePeriodHours` (0-72, default 0 = immediate revoke) and `name`; soften the 'immediate effect' Callout to note grace-period behavior.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Response Headers section claims X-RateLimit-* headers; key-management routes have no rate-limit middleware", + "severity": "MEDIUM", + "category": "rate-limit", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "'All key management endpoints return standard rate limit headers' followed by X-RateLimit-Limit/Remaining/Reset, X-Data-Delay, X-Request-Id (lines 368-378).", + "reality": "The /account/keys routes are registered via accountCORSRoute, which wraps only corsAccountMiddleware — no rateLimitMiddleware in the chain. So X-RateLimit-* and X-Data-Delay headers are NOT emitted on these endpoints (unlike /odds* which include rateLimitMiddleware).", + "evidence": "routes.go:127-128 (accountCORSRoute used for keys), routes.go:181-187 (accountCORSRoute Handler = corsAccountMiddleware(h) only, no rate-limit mw); contrast apiKeyRoute calls at routes.go:67-75 that pass rateLimitMiddleware.", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove or correct the 'Response Headers' section for this page — these endpoints do not return X-RateLimit-*/X-Data-Delay. (X-Request-Id may still appear via the instrument layer; verify before keeping it.)", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Auth described as API-key-only; endpoints actually accept Clerk session OR API key (dual auth)", + "severity": "LOW", + "category": "auth", + "page": "content/en/api-reference/account-keys.mdx", + "docs_claim": "'All key management endpoints require authentication via an existing API key.' (line 13).", + "reality": "These routes use dual auth (authenticateClerkOrAPIKey / routeAuthDual) — a Clerk session cookie OR an X-API-Key both work. The dashboard uses Clerk; programmatic callers use the API key. Stating API-key-only is incomplete but not breaking (API key path works as documented).", + "evidence": "keys.go:238,334,508,610 (authenticateClerkOrAPIKey); routes.go:184 (Auth: routeAuthDual).", + "current_status": "still-present", + "confidence": "high", + "fix": "Reword to 'require authentication — either a logged-in dashboard (Clerk) session or an existing API key.' Optional/LOW.", + "locale_scope": "en canonical; replicates to de/es/pt-BR", + "unit": "account-keys" + }, + { + "title": "Docs claim /deeplinks/batch requires Hobby+ but route has NO tier gate (all authenticated tiers, incl. Free)", + "severity": "HIGH", + "category": "tier", + "page": "content/en/api-reference/deeplinks.mdx", + "docs_claim": "Line 20: 'Requires API key for POST /deeplinks/batch. Available to **Hobby tier and above**.'", + "reality": "Route is registered with an empty feature string (no tier gate) — apiKeyRoute(...,\"\",...). Any authenticated key (including Free tier) can call it; only an API key is required, with no feature/tier check. Confirmed live: an enterprise key succeeds, and there is no tierGateMiddleware in the route chain (only rateLimitMiddleware).", + "evidence": "routes.go:72 `apiKeyRoute(\"POST\", \"/api/v1/deeplinks/batch\", \"/api/v1/deeplinks/batch\", true, 0, \"\", true, deeplinksBatchHandler, rateLimitMiddleware)` (the \"\" is the feature gate = none); main.go:7556-7616 handler does no tier check. live: POST /api/v1/deeplinks/batch -> 200 with no tier gate in chain.", + "current_status": "still-present", + "confidence": "high", + "fix": "Either remove the 'Available to Hobby tier and above' sentence (docs say it's API-key-only, all tiers), OR add a tier gate in routes.go if Hobby+ is the intended policy. Replicate the doc fix to de/es/pt-BR line 20.", + "locale_scope": "all 4 locales (en:20, de:20, es:20, pt-BR:20)", + "unit": "deeplinks" + }, + { + "title": "Batch response example omits the ?state= query suffix that the handler actually appends to every redirect path", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/api-reference/deeplinks.mdx", + "docs_claim": "Lines 124-128 + 135: response data values are shown as bare paths e.g. \"17336125542407\": \"/api/v1/deeplink/17336125542407\" and the schema row documents \"/api/v1/deeplink/{id}\".", + "reality": "deeplinksBatchHandler always appends a ?state= suffix to the redirect path: results[id] = \"/api/v1/deeplink/\" + id + \"?state=\" + state. Confirmed live: a resolved ID returned \"/api/v1/deeplink/114187356935572?state=nj\", not the bare path the docs show.", + "evidence": "main.go:7590 `stateQuery := \"?state=\" + state`; main.go:7598,7604 `results[id] = \"/api/v1/deeplink/\" + id + stateQuery`. live: POST /deeplinks/batch {ids:[114187356935572],state:nj} -> {\"data\":{\"114187356935572\":\"/api/v1/deeplink/114187356935572?state=nj\"},...}", + "current_status": "still-present", + "confidence": "high", + "fix": "Update the example response (lines 125-126) and the schema description (line 135) to show the ?state= suffix, e.g. \"/api/v1/deeplink/17336125542407?state=nj\". Replicate to de/es/pt-BR.", + "locale_scope": "all 4 locales (response block + schema row line 135)", + "unit": "deeplinks" + }, + { + "title": "Undocumented `resolve` boolean body field on /deeplinks/batch (returns final sportsbook URLs instead of redirect paths)", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/deeplinks.mdx", + "docs_claim": "Request Body table (lines 47-50) lists only `ids` and `state`. Response section (line 120) states 'The batch endpoint returns **redirect paths**, not direct sportsbook URLs.'", + "reality": "The handler accepts a third body field `resolve bool`; when true it returns the actual resolved sportsbook URL (link) instead of the /api/v1/deeplink/{id} redirect path. This field is entirely undocumented, and the 'always returns redirect paths' statement is only true when resolve is false/omitted.", + "evidence": "main.go:7569 `Resolve bool `json:\"resolve\"` // return actual URLs instead of redirect paths`; main.go:7595-7596 `if req.Resolve { results[id] = link }`.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a `resolve` row to the Request Body table (boolean, default false, 'return final sportsbook URLs instead of redirect paths') and qualify the 'returns redirect paths' statement with 'unless resolve:true'. Replicate to de/es/pt-BR.", + "locale_scope": "all 4 locales (request-body table)", + "unit": "deeplinks" + }, + { + "title": "X-Deep-Link-Type: outcome is documented + shown in example but is NEVER emitted by the server", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/deeplinks.mdx", + "docs_claim": "Lines 209 (example header `X-Deep-Link-Type: outcome`) and 216 ('| `outcome` | Direct link to a specific bet selection (with selection ID) |') claim the redirect can return X-Deep-Link-Type: outcome.", + "reality": "The redirect endpoint only ever sets X-Deep-Link-Type to 'event' (resolved http(s) link) or 'homepage' (fallback / desktop custom-scheme swap). The value 'outcome' appears nowhere in the codebase; no code path emits it.", + "evidence": "deeplink.go:68 sets 'homepage'; deeplink.go:83 `linkType := \"event\"`; deeplink.go:86 sets 'homepage'; deeplink.go:90 `w.Header().Set(\"X-Deep-Link-Type\", linkType)`. grep for 'outcome' as a header value across *.go (non-test) = 0 hits.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the example header (line 209) to `X-Deep-Link-Type: event` and remove the `outcome` row from the value table (line 216) — leave only `event` and `homepage`. Replicate to de/es/pt-BR.", + "locale_scope": "all 4 locales (example block + value table lines 209,216)", + "unit": "deeplinks" + }, + { + "title": "Supported-sportsbooks prose count contradicts its own table (says 21, table lists 23)", + "severity": "LOW", + "category": "other", + "page": "content/en/api-reference/deeplinks.mdx", + "docs_claim": "Line 249: 'Deep links are available for 21 of 28 sportsbooks.'", + "reality": "The supported table immediately below (lines 253-275) lists 23 books all marked 'Yes' (counted programmatically), and the unsupported list (line 279) names 5 (Bet105, Bookmaker, Fliff, Rebet, SABA). 23 supported + 5 unsupported = 28 total, so the table self-reports 23 supported, not 21.", + "evidence": "deeplinks.mdx:249 prose '21 of 28'; awk count of '| Yes |' rows in lines 253-275 = 23; unsupported list line 279 = 5 books.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change '21 of 28' to '23 of 28' to match the table (or reconcile the table against actual code support — note generateDeepLinkURL in deeplink.go only templates a fallback subset; most books rely on adapter-provided deepLinks, so the table count cannot be authoritatively re-derived from code alone, but the internal inconsistency should be fixed). Replicate to de (line 249 '21 von 28') and es/pt-BR equivalents.", + "locale_scope": "all 4 locales (de confirmed '21 von 28' at line 249; es/pt-BR mirror)", + "unit": "deeplinks" + }, + { + "title": "Entire health response envelope is fabricated — real shape is flat {status,valkey,newest_odds_age_s,version,pinnacle?}, not {status,version,timestamp,checks:{...}}", + "severity": "CRITICAL", + "category": "envelope", + "page": "content/en/api-reference/health.mdx", + "docs_claim": "Healthy/degraded/down responses are `{status, version, timestamp, checks:{redis:{status,latency_ms,message}, data:{status,message}, schema:{status,message}}}` (health.mdx:65-129).", + "reality": "Handler emits a flat object `{status, valkey, newest_odds_age_s, version, pinnacle?}`. There is no `checks` object, no `timestamp`, no `redis`/`data`/`schema` subsystems, no `latency_ms`. `valkey` is a bare string ('ok'|'down'), `newest_odds_age_s` is an integer seconds (or -1).", + "evidence": "main.go:8218-8233 (resp map keys: status, valkey, newest_odds_age_s, version, +pinnacle); live: /api/v1/health -> {\"newest_odds_age_s\":1,\"pinnacle\":{...},\"status\":\"ok\",\"valkey\":\"ok\",\"version\":\"fb64830\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite all three response samples (healthy/degraded/down) and the Response Fields tables to the real flat shape: top-level `status` (ok|degraded|down|starting), `valkey` (string ok|down), `newest_odds_age_s` (integer; -1 when unknown), `version` (build SHA string), and optional `pinnacle` object (per-sport CDN fetch-ratio panel: each sport carries cdn_fetch_pct_1h, cdn_fetch_pct_24h, optional note; plus updated_at). Delete the entire `checks`/`redis`/`data`/`schema`/`timestamp`/`latency_ms` invention.", + "locale_scope": "Replicates to all 4 locales (de/es/pt-BR each have the same fabricated checks block) AND to public/openapi.json getHealth.", + "unit": "health" + }, + { + "title": "JS and Python example code destructures health.checks — throws / prints undefined against the real flat response", + "severity": "HIGH", + "category": "example-breaks", + "page": "content/en/api-reference/health.mdx", + "docs_claim": "JS example iterates `Object.entries(health.checks)` (lines 36-40); Python example iterates `health['checks'].items()` and reads `check['status']`, `check.get('message')`, `check.get('latency_ms')` (lines 54-56, 251-256).", + "reality": "The real response has no `checks` key. JS `Object.entries(health.checks)` throws `TypeError: cannot convert undefined to object`. Python `health['checks']` raises `KeyError: 'checks'`. The 'Uptime Dashboard' and 'Pre-Request Health Verification' use-case snippets fail the same way.", + "evidence": "health.mdx:36-40,54-56,251-256 vs main.go:8218-8233 (no 'checks' key emitted); live response has no 'checks'.", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the JS/Python examples against the flat shape: check `health.status`, read `health.valkey` and `health.newest_odds_age_s` directly. Remove all `health.checks` iteration. Example: Python `print(health['valkey'], health['newest_odds_age_s'])`; JS `if (health.status !== 'ok') console.warn(health.status, health.valkey)`.", + "locale_scope": "Replicates to all 4 locales (same broken example code in de/es/pt-BR).", + "unit": "health" + }, + { + "title": "version documented/exampled as \"v1\" but is actually the build git SHA", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/health.mdx", + "docs_claim": "`version` example value is `\"v1\"` (health.mdx:68,94,120) and Response Fields describes it as 'Current API version' (line 148).", + "reality": "`version` is the build SHA injected via `-X main.version` ldflags. Live value observed: `\"fb64830\"`. It is NOT the API version string 'v1'.", + "evidence": "main.go:8228 (\"version\": version, with comment 'Build SHA (-X main.version ldflags)'); live: /api/v1/health -> \"version\":\"fb64830\"", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the example value from 'v1' to a representative short git SHA (e.g. 'fb64830') and reword the field description to 'Build SHA of the running API binary'.", + "locale_scope": "Replicates to all 4 locales AND public/openapi.json (example: 'v1').", + "unit": "health" + }, + { + "title": "status enum omits the 'starting' (503 cold-start) value", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/health.mdx", + "docs_claim": "Status Values table lists only `ok` (200), `degraded` (200), `down` (503); top-level field says status is 'ok, degraded, or down' (health.mdx:147,154-158).", + "reality": "Handler also returns `status:\"starting\"` with HTTP 503 during cold start (before apiStarted flips), used by the blue-green deploy health gate.", + "evidence": "main.go:8189-8196 (`status = \"starting\"; statusCode = 503` when !apiStarted.Load()).", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a `starting` row to the Status Values table (HTTP 503, 'Process up but cold-start/cache-prime not finished') and include it in the top-level status field's enum.", + "locale_scope": "Replicates to all 4 locales (status enum drift). Note: openapi.json 200-response enum is only [ok,degraded] and 503 enum is only [down] — also missing 'starting'.", + "unit": "health" + }, + { + "title": "503/down example omits version (and degraded example invents schema-drift messages the handler never emits)", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/api-reference/health.mdx", + "docs_claim": "Down (503) sample is `{status,version,timestamp,checks:{redis:{status:'down',message:'Redis unreachable'}}}` (health.mdx:117-129); degraded sample shows `schema:{status:'degraded',message:'Schema drift: missing fields [selectionType] in draftkings snapshot'}` (lines 105-108).", + "reality": "On GET, the handler ALWAYS encodes the same flat map regardless of status, so a down response is `{status:'down', valkey:'down', newest_odds_age_s:-1, version:''}` — version is present, there is no per-check 'Redis unreachable' message, and the schema-drift detector / 'missing fields' messages do not exist anywhere in the handler.", + "evidence": "main.go:8217-8233 (single resp map built for all non-HEAD responses; no schema check, no per-subsystem message strings); main.go:8200-8203 (down sets valkey='down', no message field).", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the degraded and down samples with the real flat down/degraded payloads (e.g. degraded: `{status:'degraded', valkey:'ok', newest_odds_age_s:120, version:''}`; down: `{status:'down', valkey:'down', newest_odds_age_s:-1, version:''}`). Remove all schema-drift / 'missing fields' narrative and the 'Schema Check'/'Redis Check'/'Data Check' field tables.", + "locale_scope": "Replicates to all 4 locales AND openapi.json 503 example.", + "unit": "health" + }, + { + "title": "Docs omit the live pinnacle CDN-cadence panel from the health response", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/health.mdx", + "docs_claim": "No mention of a `pinnacle` field anywhere on the page; response samples show only status/version/timestamp/checks.", + "reality": "When the Pinnacle publish-cadence refresher has populated a snapshot, the response includes a top-level `pinnacle` object: `{sports:{:{cdn_fetch_pct_1h, cdn_fetch_pct_24h, note?}}, updated_at}`. It is present in the live response right now.", + "evidence": "main.go:8230-8232 (if cadence := getPinnacleCadence(); cadence != nil { resp[\"pinnacle\"] = cadence }); live: /api/v1/health -> top-level \"pinnacle\":{\"sports\":{...},\"updated_at\":\"...\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Document the optional `pinnacle` field (present when CDN-cadence data is available): per-sport CDN fetch ratios for self-diagnosing whether stale data is Pinnacle-side low cadence vs a pipeline stall. Mark it optional.", + "locale_scope": "Missing in all 4 locales (none document pinnacle) and in openapi.json.", + "unit": "health" + }, + { + "title": "OpenAPI getHealth schema is wrong — requires fabricated [status,version,timestamp,checks] and defines a HealthCheck schema that does not exist", + "severity": "HIGH", + "category": "openapi-drift", + "page": "public/openapi.json (/health)", + "docs_claim": "getHealth 200 schema declares `required:[status,version,timestamp,checks]` with `checks:{redis,data,schema}` each `$ref HealthCheck` ({status,latency_ms,message}); description says 'Returns ... Redis connectivity, data freshness, and schema compatibility'; example uses '28 sportsbook snapshots available' and 'All 14 required fields present'.", + "reality": "Real 200 body is flat `{status, valkey, newest_odds_age_s, version, pinnacle?}`. No `timestamp`, no `checks`, no `HealthCheck` sub-objects, no schema-compatibility check. `version` is a SHA not 'v1'.", + "evidence": "openapi.json /health get.responses.200.schema (required:[status,version,timestamp,checks]) + components.schemas.HealthCheck; vs main.go:8218-8233 + live response.", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the getHealth response schema and example to the flat shape (status/valkey/newest_odds_age_s/version + optional pinnacle), add 'starting' to the status enum, drop the HealthCheck schema (or repurpose), and correct the operation description to drop 'Redis latency' and 'schema compatibility' claims.", + "locale_scope": "Single shared spec (public/openapi.json) — affects all locales.", + "unit": "health" + }, + { + "title": "Concurrent stream limit table says 10; real default is 1 (newer-wins), no 429 table", + "severity": "HIGH", + "category": "tier", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "Concurrent Stream Limits table (L543-545): 'WebSocket Add-on ($99/mo) -> 10' concurrent streams, 'Enterprise -> Custom'. Callout L548-550: exceeding returns 429 too_many_streams.", + "reality": "Per-key cap is 1 concurrent stream for EVERY paid tier (free=0), shared across WS+SSE via streamSlots with NEWER-WINS displacement: a 2nd connect from the same key tears down the older SSE writer and the new one succeeds. 429 too_many_streams is only returned when the slot cap <=0 (free / unset tier). Override via Unkey maxStreams metadata.", + "evidence": "tiers.go:137-144 (defaultMaxConcurrentStreams returns 1 for paid, 0 for free); streaming.go:728-733 (maxSSEStreams); sse.go:330-348 (Acquire(...,sseMax); slot==nil -> 429 only when sseMax<=0); sse.go:326-329 comment 'default per-tier max of 1, a second connect kicks the older session (newer-wins)'", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the table: state per-key cap = 1 concurrent stream on every paid tier (shared WS+SSE), with newer-wins displacement (older stream torn down, new succeeds). Enterprise/fleets raise via Unkey maxStreams. 429 too_many_streams only on free/unset (no slot). This is prior-audit H12.", + "locale_scope": "replicates to all 4 locales (de, es, pt-BR mirror en)", + "unit": "stream-sse" + }, + { + "title": "Documented min_ev default is 2.0; SSE handler default is 0.1", + "severity": "MEDIUM", + "category": "param-noop", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "Query Parameters table L38: min_ev default 2.0 ('Minimum EV percentage for +EV opportunity events').", + "reality": "SSE handler passes Defaults.MinEV = 0.1; when min_ev is omitted, ResolveOpportunityDefaults sets it to 0.1. The connected event echoes min_ev:0.1. Effective default is 0.1, not 2.0.", + "evidence": "sse.go:362-369 (Defaults{MinEV:0.1}), sse.go:381-384, pkg/filters/parse.go:174-177 (default fills from opts.Defaults.MinEV); live: SSE connect on OVH :3003 -> connected event data.filters.min_ev = 0.1", + "current_status": "still-present", + "confidence": "high", + "fix": "Change min_ev default in the Query Parameters table from 2.0 to 0.1.", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "Reconnect section says 'fresh full snapshot, not a replay' but SSE resume IS enabled in prod (odds channel)", + "severity": "MEDIUM", + "category": "conceptual", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "Reconnection L286: 'When the client reconnects, the server delivers a fresh full snapshot -- not a replay of individual missed events.' Best Practices L592: 'Use Last-Event-ID -- Enables the server to replay missed events after a reconnection.' (self-contradictory)", + "reality": "SSE_RESUME_ENABLED=1 in prod. On a resumable reconnect (odds channel, Last-Event-ID within the replay buffer) the server REPLAYS buffered odds:update/odds:locked deltas verbatim and emits snapshot:complete with mode:'resume', replayed_count -- it does NOT send a fresh full snapshot. Only when resume fails (fallback) does it fall back to the full snapshot path. Resume is odds-channel-only.", + "evidence": "ssh ovh /opt/sharpapi/.env -> SSE_RESUME_ENABLED=1; sse.go:464-475,533-555 (resume replay branch + snapshot:complete mode:resume); live: connected event data.resume = {durable:false,enabled:true,resumable_channels:[\"odds\"],scope:\"process_local\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Reconcile the two lines. Document: reconnect with Last-Event-ID on the odds channel attempts a delta REPLAY from the in-process buffer (snapshot:complete carries mode:'resume', replayed_count); on replay miss / non-odds channel it falls back to a fresh full snapshot. Prior-audit M11 (now line 286 is the wrong one because resume is live).", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "channel param description and connected-event echo omit 'gamestate' from the channel value list", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "connected event field table L80: 'channel | Echo of requested channel (odds, opportunities, or all)' -- omits gamestate. (Inconsistent with L32/L49/L62 which DO list gamestate as a valid channel.)", + "reality": "gamestate is a valid channel value and is echoed verbatim in the connected event's channel field. Handler validates channel in {odds, opportunities, gamestate, all}.", + "evidence": "sse.go:277-279 (valid channels include gamestate); sse.go:490-491 (connected payload 'channel': channel echoes the requested value)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add gamestate to the parenthetical list in the connected event's channel field description (odds, opportunities, gamestate, or all).", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "connected event documents 'trial' object but live payload has no trial; many real fields undocumented", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "connected event field table L77-83 lists: stream_id, channel, filters, reconnected, trial (object). It does NOT list connected_at, initial, resume, server, tier, version. It documents 'reconnected' as a present field and a 'trial' object.", + "reality": "Live connected payload = {channel, connected_at, filters, initial, resume{durable,enabled,resumable_channels,scope}, server, stream_id, tier, version}. No 'trial' object is emitted (trial users get streaming via resolveStreamingTrial but no trial block is added to the connected payload in current code). 'reconnected' is ABSENT on a fresh connect (only added on reconnect, never set to false).", + "evidence": "sse.go:489-520 (connectedPayload: stream_id, channel, filters, tier, server, version, connected_at, initial; reconnected/last_event_id only if reconnected; resumed/replayed_count/fallback_reason only if resumeAttempted); live: connected event data keys = channel,connected_at,filters,initial,resume,server,stream_id,tier,version (no trial, no reconnected)", + "current_status": "still-present", + "confidence": "high", + "fix": "Drop the undocumented/incorrect 'trial' row (or verify a code path emits it). Add the real fields: connected_at, initial, resume{...}, server, tier, version. Note reconnected/last_event_id/resumed/replayed_count/fallback_reason appear only on reconnect.", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "connected event 'filters' example shows arrays/null; live emits a flat string object with extra keys", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "connected example L74: data.filters = {sportsbook:null, sport:[\"basketball\"], league:[\"nba\"], event:null, market:null} -- arrays for sport/league, null for unset, no live/min_ev/min_profit keys.", + "reality": "filters echo is a FLAT object of raw query strings: unset values are empty strings (\"\") not null; sport/league are comma-joined strings (e.g. \"basketball\") not arrays; the object ALSO contains live, min_ev, and min_profit keys not shown in the example.", + "evidence": "sse.go:492-501 (filters map: sport/league/sportsbook/market/event via q.Get(...) -> strings; plus live, min_ev, min_profit); live: connected event data.filters = {event:\"\",league:\"nba\",live:\"\",market:\"\",min_ev:0.1,min_profit:0.5,sport:\"basketball\",sportsbook:\"\"}", + "current_status": "still-present", + "confidence": "high", + "fix": "Update the connected example to the real flat shape: filters = {sport, league, sportsbook, market, event (empty strings when unset), live, min_ev, min_profit}. Remove the array/null depiction.", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "filter_warning SSE event is emitted but completely undocumented", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "SSE Event Types section (L66-282) documents connected, snapshot, snapshot:complete, odds:update, ev/arb/middles/low_hold:detected/expired, odds:locked, odds:removed, heartbeat, error. No mention of a filter_warning event.", + "reality": "After 'connected', the handler emits a 'filter_warning' SSE event when a filter value is invalid or a requested sportsbook is tier-blocked (so streaming clients diagnose silent-empty filtering instead of getting no data).", + "evidence": "sse.go:524-531 (buildFilterWarningPayload -> writeSSE 'filter_warning'); filter_helpers.go:231 (buildFilterWarningPayload); sse.go:360-361 comment 'invalid values produce a filter_warning event after connected'", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a filter_warning event type to the SSE Event Types list documenting it fires after connected when filters are invalid or a sportsbook is tier-blocked.", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "Compact-delta Callout/Migration list an older 7-field set, contradicting the page's own full delta table", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/stream.mdx", + "docs_claim": "Callout L120 and Migration L597 state deltas contain only id, odds_american, odds_decimal, odds_probability, line, is_live, timestamp -- and explicitly that is_active/is_main_line/is_alternate_line/is_stale_pregame_price are NOT in deltas. But the page's own delta field table L129-145 DOES list is_active, is_main_line, is_alternate_line, is_stale_pregame_price as delta fields.", + "reality": "The compact OddsDelta projection (live in prod, STREAM_COMPACT_DELTAS=1) carries ALL of: id, odds_american, odds_decimal, odds_probability, line, is_live, is_active, timestamp, is_alternate_line, is_main_line, is_stale_pregame_price, plus optional volume/volume_24h/open_interest/max_bet and polymarket_resolution. The 7-field claim is stale; the detailed table is closer to correct but omits polymarket_resolution.", + "evidence": "compact_delta.go:37-54 (OddsDelta struct fields); compact_delta.go:33-36 comment 'doc's older 7-field OddsDelta table predates is_alternate_line/is_main_line/is_stale_pregame_price/is_active'; prod /opt/sharpapi/.env STREAM_COMPACT_DELTAS=1", + "current_status": "new", + "confidence": "high", + "fix": "Make the Callout (L120) and Migration section (L597) field list match the OddsDelta struct + the page's own table: id + odds_american/decimal/probability, line, is_live, is_active, is_main_line, is_alternate_line, is_stale_pregame_price, timestamp, optional exchange volumes + polymarket_resolution. Drop the 'only 6-7 fields / is_active not included' wording.", + "locale_scope": "replicates to all 4 locales", + "unit": "stream-sse" + }, + { + "title": "Close code 4029 does not exist (server never sends it); over-limit is HTTP 429 at upgrade or 4001 displacement", + "severity": "HIGH", + "category": "errcode", + "page": "api-reference/websocket.mdx", + "docs_claim": "Close Codes table (line 614) documents `4029` 'Stream limit exceeded'; Concurrent-Stream-Limits warning (line 906) and Best Practices #6/#11 (lines 916,921) say exceeding the limit closes with `4029`.", + "reality": "No `4029` exists anywhere in the Go code (grep -rn 4029 over all .go = 0 hits). When the per-key slot cap is exceeded the server returns HTTP 429 with errcode `too_many_streams` AT THE UPGRADE (websocket.go:2566-2581), and that only happens for free/unset tier (maxStreams<=0). For every paid tier the default is newer-wins: the OLDER socket is closed with close frame `4001 'displaced by newer session'` (websocket.go:2864-2868, watchKickedWS) and the new connection succeeds.", + "evidence": "websocket.go:2566-2581 (HTTP 429 too_many_streams at upgrade); websocket.go:2864-2868 (4001 displaced by newer session); grep '4029' across *.go = no matches; CLAUDE.md 'Connection limits (2026-04-27)' confirms newer-wins 4001", + "current_status": "still-present", + "confidence": "high", + "fix": "Delete the `4029` row from the Close Codes table and the 4029 references in the Concurrent-Stream-Limits warning + Best Practices #6/#11. Replace with: over-limit reject is HTTP 429 (`too_many_streams`) only for free/unset tier; for paid tiers a second connection on the same key displaces the older one via close `4001 'displaced by newer session'`. Note 4001's dual meaning (auth-failed OR displaced).", + "locale_scope": "all 4 locales (en/de/es/pt-BR)", + "unit": "websocket" + }, + { + "title": "Concurrent stream limit is 1 (newer-wins), not 10", + "severity": "HIGH", + "category": "tier", + "page": "api-reference/websocket.mdx", + "docs_claim": "Concurrent Stream Limits table (lines 900-904) says 'WebSocket Add-on ($99/mo) -> 10' max concurrent streams. Best Practice #11 (line 921) partly contradicts this with 'Each key allows 1 concurrent stream by default'.", + "reality": "Default per-key cap is 1 concurrent stream for EVERY tier, shared across WS+SSE via streamSlots, with newer-wins displacement (older session closed with 4001). Only enterprise/fleet customers raise it via Unkey maxStreams metadata. There is no '10' default.", + "evidence": "websocket.go:2561-2565 (maxStreams = defaultMaxConcurrentStreams(tier)); streamslots.go newer-wins displacement; CLAUDE.md 'Per-key cap is 1 concurrent stream by default for every tier' + tiers.go defaultMaxConcurrentStreams=1; reality brief confirms defaultMaxConcurrentStreams=1, NOT 10", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the table value '10' with '1 (newer-wins displacement; older socket closed with 4001)'. Note the limit is shared across WS+SSE and is overridable via Unkey maxStreams metadata for Enterprise/fleet customers. Reconcile with Best Practice #11 which already states '1 by default'.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "opportunities_snapshot arrays are nested under `data`, not at top level — all 4 code examples access them wrong", + "severity": "HIGH", + "category": "example-breaks", + "page": "api-reference/websocket.mdx", + "docs_claim": "opportunities_snapshot example (lines 192-222) shows the array at top level: `{type, seq, ev: [...], timestamp}`; line 225 says 'The top-level key matches the channel type'. Code examples then read `msg.ev` (Browser line 704), `msg.low_hold` (Node line 763), `'middles' in msg` (Python line 809), `({ ev, low_hold }) =>` (TS line 857).", + "reality": "The real message nests the opportunity array one level down: `{type:'opportunities_snapshot', data: {ev: [...]}}` (or data.low_hold / data.middles / etc.). There is no top-level `ev`/`low_hold`/`middles` key, no `seq`, and no `timestamp` on this message. Every documented client snippet that does `if (msg.ev)` / `msg.low_hold` / `'middles' in msg` would silently never match.", + "evidence": "websocket.go:4315-4319 ({\"type\":\"opportunities_snapshot\",\"data\":{opType: chunk}}) and websocket.go:4605-4612 (same shape in the subscribe-snapshot path); doc lines 704,763,809,857 read the wrong path", + "current_status": "still-present", + "confidence": "high", + "fix": "Show the real shape `{type:'opportunities_snapshot', data:{ev:[...]}}` and update the four code tabs to read `msg.data.ev` / `msg.data.low_hold` / `msg.data.middles`. Remove the non-existent top-level `seq`/`timestamp` from the example. (AsyncAPI spec opportunities_snapshot also documents top-level arrays — same drift, separate unit.)", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`initial` message uses `book`, not `source`; has no seq/count/timestamp; `data` is an array not an object", + "severity": "HIGH", + "category": "example-breaks", + "page": "api-reference/websocket.mdx", + "docs_claim": "initial example (lines 235-244) shows `{type, seq, source:'draftkings', data:[...], count:1500, timestamp}`. Browser code example (line 707) does `const books = Object.keys(msg.data)` treating `data` as an object keyed by book.", + "reality": "The real `initial` message is `{type:'initial', book:'draftkings', data:[ NormalizedOdds... ]}`. The per-book field is `book` (singular), NOT `source`. There is no `seq`, no `count`, no `timestamp`. `data` is an ARRAY of odds, so `Object.keys(msg.data)` yields array indices, not book names.", + "evidence": "websocket.go:4414-4418 ({\"type\":\"initial\",\"book\":book,\"data\":chunk}); doc line 707 `Object.keys(msg.data)` is wrong for an array", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the `initial` example to `{type:'initial', book:'draftkings', data:[...]}`; drop `source`, `seq`, `count`, `timestamp`. Fix the Browser snippet to read `msg.book` and treat `msg.data` as an array (`msg.data.length`). (AsyncAPI spec Initial also wrongly uses `source`+`count` — same drift.)", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`subscribed` message shape is wrong: real msg is per-channel `{channel, filters:{...}}`, not a single flat multi-channel object", + "severity": "HIGH", + "category": "envelope", + "page": "api-reference/websocket.mdx", + "docs_claim": "subscribed example (lines 172-185) shows ONE message `{type, seq, channels:[...], sports, sportsbooks, leagues, markets, eventIds, min_ev, min_profit, timestamp}` with flat top-level filter arrays.", + "reality": "The server sends one `subscribed` message PER channel: `{type:'subscribed', channel:'ev', filters:{...}, filter_warnings?}`. Field is `channel` (singular string), filters are NESTED in a `filters` object using SINGULAR keys (`sport`, `league`, `sportsbook`, `market`, `event`, plus `live`, `min_ev`, `min_profit`, `min_odds`, `max_odds`). No `seq`, no `timestamp`, no flat top-level filter arrays, no `channels` array, no `sportsbooks`/`leagues`/`eventIds` plurals.", + "evidence": "websocket.go:3579-3589 (per-channel ack {type:subscribed, channel:ch, filters:c.filtersSnapshot(), filter_warnings?}); websocket.go:4869-4900 filtersSnapshot uses singular keys sport/league/sportsbook/market/event", + "current_status": "still-present", + "confidence": "high", + "fix": "Document `subscribed` as a per-channel ack: `{type:'subscribed', channel:'', filters:{sport:[...],league:[...],sportsbook:[...],market:[...],event:[...],min_ev,min_profit,...}, filter_warnings?:{...}}`. Remove `seq`/`timestamp` and the flat plural filter fields. Note one ack is emitted per subscribed channel. (AsyncAPI spec subscribed also wrong — same drift.)", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`*:expired` payloads put `expired` at top level, not nested under `data` — Node/Python examples throw", + "severity": "HIGH", + "category": "example-breaks", + "page": "api-reference/websocket.mdx", + "docs_claim": "ev:expired/arb:expired/middles:expired/low_hold:expired examples (lines 363-369, 425-431, 487-493, 545-551) all show `{type, seq, data:{expired:[...]}}`. Node example reads `msg.data.expired.length` (line 773), Python reads `msg[\"data\"][\"expired\"]` (line 817).", + "reality": "The expired list is emitted at the TOP LEVEL of the message: `{type:':expired', seq, global_seq, expired:[...]}`. There is no `data` wrapper. `msg.data.expired` / `msg[\"data\"][\"expired\"]` would be a TypeError / KeyError.", + "evidence": "websocket.go:1361-1368 ({\"type\":eventPrefix+\":expired\",\"seq\":seq,\"global_seq\":...,\"expired\":expiredIDs}); doc Node line 773 + Python line 817 read msg.data.expired", + "current_status": "still-present", + "confidence": "high", + "fix": "Change all four `*:expired` examples to `{type, seq, global_seq, expired:[...]}` (no `data` wrapper) and fix the Node (`msg.expired.length`) and Python (`msg[\"expired\"]`) snippets accordingly.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "Close code 4003 'No streaming access' does not exist; no-streaming-access closes with 4001 (or HTTP 403 at upgrade)", + "severity": "MEDIUM", + "category": "errcode", + "page": "api-reference/websocket.mdx", + "docs_claim": "Close Codes table (line 613) documents `4003` 'No streaming access — Add WebSocket add-on or upgrade to Enterprise'. The frame-error section (line 603) also implies tier_restricted closes the socket.", + "reality": "There is no `4003` anywhere in the Go code (grep -rn 4003 over *.go = 0 hits). A connection without streaming access is rejected with HTTP 403 (tier_restricted) at the upgrade for the ?api_key= path (websocket.go:2542-2552), or for v1.5 deferred-auth it sends a tier_restricted error frame then closes with `4001` (wsCloseAuthFailed), not 4003 (websocket.go:3398-3406). The reality brief's '4003 = entitlements changed' is also not backed by code.", + "evidence": "grep '4003' across *.go = no matches; websocket.go:2542-2552 (HTTP 403 tier_restricted at upgrade); websocket.go:3399-3405 (sendError TierRestricted then FormatCloseMessage(wsCloseAuthFailed=4001))", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the `4003` row. Document that no-streaming-access is rejected with HTTP 403 (`tier_restricted`) at the upgrade (?api_key= path) or, for the v1.5 deferred-auth handshake, an error frame `tier_restricted` followed by a 4001 close.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`seq` is the global sequence counter, not a per-connection counter", + "severity": "MEDIUM", + "category": "field-name", + "page": "api-reference/websocket.mdx", + "docs_claim": "connected field table (line 159) and the Sequence Numbers section (line 622, 625) describe `seq` as 'a per-connection integer that increments with each message' and distinguish it from `global_seq` (server-wide). Best Practice #7 / examples track `seq` per-connection.", + "reality": "Every server message sets `seq` from the SAME global counter (`nextGlobalSeq()` / `currentGlobalSeq()`); there is no per-connection sequence. `seq` (int) and `global_seq` (string) carry the same underlying value. So `seq` is NOT per-connection and does NOT increment 1,2,3 within a connection — it jumps with global server activity, shared across all clients.", + "evidence": "websocket.go:710,745,782,912,995,1075,1343,1362,4682,4690 (all seq = nextGlobalSeq/currentGlobalSeq); no c.seq++ counter exists (grep c.seq = none); 4691 global_seq = strconv.FormatInt(connectedSeq) — same value as seq", + "current_status": "still-present", + "confidence": "high", + "fix": "Correct the Sequence Numbers section + connected field table: `seq` is the global event sequence (int), identical in value to `global_seq` (string, JS-safe past 2^53). It is monotonic server-wide, not per-connection, so it will NOT be 1,2,3... per socket. Gap-detection still works on the global counter. Use `global_seq` (string) to avoid precision loss for `from_seq` replay.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`connected` example: wrong `books.max` for non-enterprise tiers, missing string `global_seq`, wrong welcome text, phantom `timestamp`", + "severity": "MEDIUM", + "category": "field-missing", + "page": "api-reference/websocket.mdx", + "docs_claim": "connected example (lines 142-154) for tier 'pro' shows `books:{max:-1, allowed:null}`, `message:'Welcome to SharpAPI real-time odds stream'`, `global_seq:12847` (integer), and `timestamp:'2026-02-08T18:47:17.559Z'`.", + "reality": "books.max comes from the tier config MaxBooks: pro=15, sharp=25, hobby=5, free=2; only enterprise is -1 (so `max:-1` for a 'pro' connection is wrong). The welcome string is 'Welcome to SharpAPI WebSocket'. `global_seq` is serialized as a STRING (strconv.FormatInt), not an integer. The connected message has NO `timestamp` field at all. `allowed` is an array of book IDs for non-enterprise tiers (null only when unbounded).", + "evidence": "websocket.go:4663-4666 (maxBooks=cfg.MaxBooks); websocket.go:4691 (global_seq=strconv.FormatInt → string); websocket.go:4696 (message:'Welcome to SharpAPI WebSocket'); websocket.go:4684-4697 payload has no timestamp key; tiers.go MaxBooks pro=15/sharp=25", + "current_status": "still-present", + "confidence": "high", + "fix": "Set the example's `books.max` to a non-enterprise value (e.g. 15 for pro) or note it is tier-dependent (-1 only for enterprise); change `message` to 'Welcome to SharpAPI WebSocket'; show `global_seq` as a quoted string; remove the `timestamp` field from the connected example.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`min_ev` default documented as 2.0; reality is no WS-layer default (system standard is 0.1)", + "severity": "MEDIUM", + "category": "param-noop", + "page": "api-reference/websocket.mdx", + "docs_claim": "Query Parameter table (line 58) and subscribe filters table (line 127) state `min_ev` default = 2.0.", + "reality": "The WS filter parser does NOT set ResolveOpportunityDefaults, so when `min_ev` is omitted it stays nil and no min_ev filter is applied at the WS layer (websocket.go:2320 only filters when MinEV != nil). Even the system 'standard' default is 0.1, not 2.0 (filters/defaults.go:39). So '2.0' is wrong both as a wire default and as the standard.", + "evidence": "websocket.go:2128 comment 'default 0.1 when nil'; websocket.go:2320 (filter applied only if MinEV != nil); websocket.go:5115 ParseQuery without ResolveOpportunityDefaults; pkg/filters/defaults.go:39 MinEV:0.1", + "current_status": "still-present", + "confidence": "high", + "fix": "Change `min_ev` default to reflect reality: no server-side min_ev filter is applied unless you pass `min_ev` (i.e. all engine-produced EV opps, which the EV engine itself emits at >=2.0). If a numeric default is wanted, it is 0.1, not 2.0. (`min_profit` default 0.5 is correct.)", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "`pong` response carries no `seq` or `timestamp`", + "severity": "LOW", + "category": "field-missing", + "page": "api-reference/websocket.mdx", + "docs_claim": "pong example (lines 571-577) shows `{type:'pong', seq:151, timestamp:'...'}`.", + "reality": "The application-level pong reply is exactly `{\"type\":\"pong\"}` — no `seq`, no `timestamp`.", + "evidence": "websocket.go:3059 (c.sendControlJSON(map[string]interface{}{\"type\":\"pong\"}))", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the pong example to `{ \"type\": \"pong\" }` (remove seq and timestamp).", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "unknown_message_type enum lists `refresh_token` and omits `unsubscribe`; should be `token_refresh`", + "severity": "LOW", + "category": "errcode", + "page": "api-reference/websocket.mdx", + "docs_claim": "error-codes table (line 597) says `unknown_message_type` fires when `type` is not one of `auth`, `subscribe`, `filter`, `refresh_token`, `ping`. The earlier `missing_token` row (line 598) references an `refresh_token` frame.", + "reality": "The accepted client message types are `auth`, `token_refresh`, `subscribe`, `filter`, `ping`, `unsubscribe` (readPump switch). The token-rotation type is `token_refresh`, NOT `refresh_token`; `unsubscribe` is a valid type but is missing from the documented set.", + "evidence": "websocket.go:3049-3064 (switch cases: auth, token_refresh, subscribe, filter, ping, unsubscribe; default → WSUnknownMessageType)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace `refresh_token` with `token_refresh` and add `unsubscribe` to the valid-types list in the unknown_message_type and missing_token rows.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "snapshot:complete documented fields `resumed`/`progressive` do not exist", + "severity": "LOW", + "category": "field-missing", + "page": "api-reference/websocket.mdx", + "docs_claim": "snapshot:complete example + field table (lines 254-269) show `{type, seq, books, resumed, progressive, timestamp}`.", + "reality": "The standard snapshot:complete is `{type:'snapshot:complete', books:[...], total_odds:N}` — no `seq`, no `resumed`, no `progressive`, no `timestamp`. The resume-path variant is `{type:'snapshot:complete', mode:'resume', replayed_count, skipped_count, last_seq, gap_detected?}` (different fields). Neither path emits `resumed` or `progressive`.", + "evidence": "websocket.go:4428-4432 (standard: type/books/total_odds); websocket.go:3933-3946 (resume: type/mode/replayed_count/skipped_count/last_seq/gap_detected)", + "current_status": "still-present", + "confidence": "high", + "fix": "Document the real fields: standard `{type, books, total_odds}`; resume `{type, mode:'resume', replayed_count, skipped_count, last_seq, gap_detected?}`. Remove `seq`, `resumed`, `progressive`, `timestamp`.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "odds:removed example shows nonexistent `count` and `timestamp` fields", + "severity": "LOW", + "category": "field-missing", + "page": "api-reference/websocket.mdx", + "docs_claim": "odds:removed example (lines 308-316) shows `{type, seq, source, ids, count, timestamp}`.", + "reality": "The real odds:removed message is `{type:'odds:removed', seq, global_seq, source, ids:[...]}` — no `count`, no `timestamp`.", + "evidence": "websocket.go:746-752 (pre-serialized) and websocket.go:1024-1030 (slow path): keys type/seq/global_seq/source/ids only", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove `count` and `timestamp` from the odds:removed example; optionally add the `global_seq` (string) field that is actually present.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "heartbeat example omits the `connected`, `server`, and `global_seq` fields that are actually sent", + "severity": "LOW", + "category": "field-missing", + "page": "api-reference/websocket.mdx", + "docs_claim": "heartbeat example (lines 559-564) shows only `{type:'heartbeat', seq:150, timestamp}`.", + "reality": "The real heartbeat is `{type:'heartbeat', timestamp, connected:, server:'sharp-api-go', seq:, global_seq:''}`. The `seq` here is the global counter (see seq finding), and there are three extra fields.", + "evidence": "websocket.go:1965-1972 (type/timestamp/connected/server/seq/global_seq)", + "current_status": "still-present", + "confidence": "medium", + "fix": "Add `connected`, `server`, and `global_seq` to the heartbeat example, or note additional informational fields may be present; clarify `seq` is the global counter.", + "locale_scope": "all 4 locales", + "unit": "websocket" + }, + { + "title": "Reference Data endpoints documented as public (Auth: No) but require an API key", + "severity": "CRITICAL", + "category": "auth", + "page": "content/en/api-reference/overview.mdx", + "docs_claim": "overview.mdx:93-101 'Reference Data (Public)' table lists GET /api/v1/sports, /leagues, /sportsbooks, /markets, /teams with Auth = 'No' and Min Tier = '-'.", + "reality": "All five are registered with apiKeyRoute (auth required, ref cache 5s). A request without a key returns 401 missing_api_key. There is no public alias for any of them.", + "evidence": "routes.go:75-79 (apiKeyRoute for sports/leagues/sportsbooks/markets/teams, none publicRoute); live: GET /api/v1/sports (no key) -> HTTP 401 {\"code\":\"missing_api_key\"}", + "current_status": "new", + "confidence": "high", + "fix": "Change the 'Reference Data (Public)' heading to 'Reference Data' and set Auth = 'Yes', Min Tier = 'Free' for all five rows; remove '(Public)'. Customer code copying these as keyless calls will 401.", + "locale_scope": "Replicates to all 4 locales (de, es, pt-BR mirror en).", + "unit": "conventions-overview" + }, + { + "title": "overview error table: retry_after labeled 'Seconds until retry' but is actually a Unix-ms timestamp", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/api-reference/overview.mdx", + "docs_claim": "overview.mdx:201 'error.retry_after | Seconds until retry is allowed (optional — included on rate_limited errors)'.", + "reality": "On a 429, error.retry_after is a Unix-milliseconds timestamp (resetMs = (minuteBucket*60+60)*1000), NOT a seconds duration. The seconds-until-retry value lives in a separate camelCase field error.retryAfter. A client using retry_after as a sleep duration would sleep ~1.78e12 ms.", + "evidence": "ratelimit.go:117-128 — `// Match Node.js format: retry_after is Unix ms timestamp`; resetMs:=(minuteBucket*60+60)*1000; envelope emits both \"retry_after\":resetMs and \"retryAfter\":retryAfter(seconds).", + "current_status": "new", + "confidence": "high", + "fix": "Correct the description: retry_after = Unix-milliseconds timestamp of the next window. Document that seconds-until-retry is in the camelCase retryAfter field, or steer clients to the standard Retry-After header (seconds). Note both fields are emitted.", + "unit": "conventions-overview" + }, + { + "title": "conventions error table omits required_tier on 403 (only documents tier)", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/conventions.mdx", + "docs_claim": "conventions.mdx:99 error-field table lists only `tier` for 403 ('The tier that would unlock the endpoint'); no `required_tier` row.", + "reality": "tier_restricted 403 bodies emit BOTH `tier` (caller's current tier) and `required_tier` (lowest unlocking tier). The gamestate addon gate adds `addon:\"game_state\"` instead of required_tier. conventions.mdx conflates the two into one 'tier' row.", + "evidence": "tiers.go:333-340 (tier + required_tier) and tiers.go:377-384 (tier + addon); overview.mdx:346 already documents required_tier correctly.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a `required_tier` (On 403) row and clarify `tier` = caller's current tier vs `required_tier` = minimum unlocking tier. Optionally note the gamestate-addon variant carries `addon` not `required_tier`.", + "unit": "conventions-overview" + }, + { + "title": "conventions error example + field table use camelCase retryAfter, breaking the page's own snake_case rule and hiding snake retry_after", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/api-reference/conventions.mdx", + "docs_claim": "conventions.mdx:88 example shows `\"retryAfter\": 3` and :98 documents only `retryAfter` ('Seconds until the client should retry. On 429/5xx'); same page (:120,:190) declares 'snake_case everywhere' for server-to-client fields.", + "reality": "The 429 envelope emits snake_case `retry_after` (Unix ms) AND camelCase `retryAfter` (seconds). Documenting only the camelCase variant contradicts the snake_case convention and hides the snake field a generic parser expects. retryAfter is emitted on 429 rate-limit, not generically on 5xx as implied.", + "evidence": "ratelimit.go:122-129 (both retry_after + retryAfter on 429); conventions.mdx:120,190 (snake_case claim) vs :88,:98 (camelCase only).", + "current_status": "still-present", + "confidence": "high", + "fix": "Document snake_case `retry_after` (Unix ms) as primary; mention `retryAfter` (seconds) as a legacy duplicate; update the example to snake_case; drop the '/5xx' wording unless verified.", + "unit": "conventions-overview" + }, + { + "title": "Error-code count is stale: docs say 21 HTTP codes; code has 23 (overview table omits book_not_selected + invalid_filter)", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/api-reference/conventions.mdx", + "docs_claim": "conventions.mdx:105 'the full list (21 HTTP codes + 6 WebSocket frame codes ...)'; overview.mdx:338-360 HTTP error table documents exactly those 21 codes.", + "reality": "pkg/errcodes defines 23 HTTP error codes + 6 WS frame codes. Missing from the overview table: `book_not_selected` (403, dashboard selection excludes a requested book) and `invalid_filter` (400, filter field failed registry validation). The 21 documented codes all exist; WS codes match.", + "evidence": "pkg/errcodes/errcodes.go (HTTP const block = 23 entries incl. book_not_selected, invalid_filter; WS block = 6); diff vs overview.mdx table shows the 2 absent.", + "current_status": "still-present", + "confidence": "high", + "fix": "Set conventions.mdx:105 to '23 HTTP codes + 6 WebSocket frame codes'. Add book_not_selected (403) and invalid_filter (400) rows to the overview HTTP error table; their detail shapes are in errcodes/details.go (invalid_filter -> {fields:{...}}, book_not_selected -> {field,requested,selected,tier}).", + "unit": "conventions-overview" + }, + { + "title": "Prior-audit correction: book_unavailable and cursor_expired are NOT in shipped code (worktree-only)", + "severity": "LOW", + "category": "errcode", + "page": "content/en/api-reference/overview.mdx", + "docs_claim": "DOCS_REALITY_AUDIT_2026-06-21.md M20 and this run's brief state 25 HTTP codes with 4 missing: book_not_selected, book_unavailable, cursor_expired, invalid_filter.", + "reality": "book_unavailable and cursor_expired exist ONLY in .claude/worktrees/** (pr-1109 / issue-973), which are unmerged and out of scope. The authoritative checkout has 23 HTTP codes; only book_not_selected + invalid_filter are real-and-undocumented. Documenting the other two would introduce non-existent codes.", + "evidence": "grep book_unavailable|cursor_expired across non-worktree .go = 0 matches (only under .claude/worktrees/pr-1109 + issue-973); errcodes.go HTTP block = 23 constants.", + "current_status": "not-a-bug", + "confidence": "high", + "fix": "Do NOT add book_unavailable or cursor_expired while unmerged; only add book_not_selected + invalid_filter. Revisit if pr-1109/issue-973 land in main.", + "unit": "conventions-overview" + }, + { + "title": "Tier table claims Sharp gets 'All 32' books; reality Sharp capped at 25 (only Enterprise unlimited)", + "severity": "MEDIUM", + "category": "tier", + "page": "content/en/api-reference/overview.mdx", + "docs_claim": "overview.mdx:257 Tier Comparison 'Sportsbooks' row: Sharp 'All 32', Enterprise 'All 32'.", + "reality": "Sharp MaxBooks = 25 (deliberate cap for 'Choose any 25'); only Enterprise is unlimited (-1). Total book universe isn't 32 either — defaultBookPriority lists 34 entries; book-count claims vary across pages.", + "evidence": "tiers.go:46-58 (sharp MaxBooks:25, enterprise MaxBooks:-1); tiers.go:159-167 defaultBookPriority = 34 entries.", + "current_status": "still-present", + "confidence": "high", + "fix": "Sharp -> '25 (choose any)'; Enterprise -> 'All / unlimited'. Pick one canonical total-book figure and apply uniformly (overview/pricing/index disagree). Don't merge Sharp+Enterprise into one 'All' cell.", + "unit": "conventions-overview" + }, + { + "title": "Opportunities endpoint table omits /opportunities/low_hold (and '35 routes' count likely stale)", + "severity": "LOW", + "category": "missing-endpoint", + "page": "content/en/api-reference/overview.mdx", + "docs_claim": "overview.mdx:62-68 Opportunities table lists only /opportunities/ev (Pro), /arbitrage (Hobby), /middles (Pro); overview.mdx:40 asserts 'SharpAPI exposes 35 routes across 7 namespaces'.", + "reality": "GET /api/v1/opportunities/low_hold exists (feature 'low_hold', Pro+) and GET /api/v1/opportunities (no gate) exists; low_hold is absent from this summary table. The '35 routes' count is unverified and likely stale.", + "evidence": "routes.go registers /api/v1/opportunities/low_hold (low_hold gate) + base /opportunities; tiers.go:43,54 list low_hold in pro/sharp Features.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a /api/v1/opportunities/low_hold row (Auth Yes, Min Tier Pro); consider the ungated /api/v1/opportunities aggregate. Re-derive the route count from routes.go or drop the hard number.", + "unit": "conventions-overview" + }, + { + "title": "conventions paginated example shows total without the omission caveat (/odds family omits it)", + "severity": "LOW", + "category": "pagination", + "page": "content/en/api-reference/conventions.mdx", + "docs_claim": "conventions.mdx:51-66 paginated example includes `\"total\": 1247` and prose '`total` is the full matching set' with no caveat it can be absent.", + "reality": "The /odds* family (heaviest paginated endpoints) omit pagination.total — confirmed live (pagination = {limit,offset,count,has_more,next_offset,next_cursor}, no total). overview.mdx:158 already carries this caveat; conventions.mdx does not.", + "evidence": "live: GET /api/v1/odds?limit=2 -> pagination has no `total` key; overview.mdx:158 documents the omission.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a one-line caveat to conventions.mdx that `total` is omitted on endpoints that can't compute it cheaply (e.g. /odds), mirroring overview.mdx:158; generic parsers must treat total as optional.", + "unit": "conventions-overview" + }, + { + "title": "Live GA endpoint /odds/export entirely absent from OpenAPI spec", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "openapi.json lists 35 paths; /odds/export is not among them (no path object).", + "reality": "GET /api/v1/odds/export is live GA — returns flat per-row odds export (fields id, sportsbook, event_id, event_uuid, sport, league, teams, market_type, selection, odds_american/decimal/probability, line, event_start_time...). Requires an API key (401 with no key) but has NO tier feature gate (featureNone), so any authenticated tier including Free can bulk-export.", + "evidence": "live: GET /api/v1/odds/export?sport=baseball&limit=1 -> 200 {\"id\":\"28949350069095\",\"sportsbook\":\"fanatics\",...}; live no-key -> 401; docs.sharpapi.io/openapi.json -> /odds/export MISSING. (Not in this checkout's routes.go — checkout 1eb8d6b is stale; endpoint landed after.)", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a /odds/export path object to openapi.json documenting the flat-row export response shape, the format/limit/sport query params, the cache-bypass/streaming behavior, and the fact that it is auth-required but ungated (available to all tiers including Free). Mirror to all 4 locales' reference nav.", + "locale_scope": "all 4 locales (machine spec is shared; nav addition would replicate to de/es/pt-BR)", + "unit": "census-openapi" + }, + { + "title": "Live GA endpoint /injuries entirely absent from OpenAPI spec", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "openapi.json has no /injuries path.", + "reality": "GET /api/v1/injuries is live GA — returns {data:[{player, player_position, espn_player_id, espn_team_id, team, sport, league, status, status_abbr, type, side, return_date, comment}...]}. Confirmed real injury data for MLB.", + "evidence": "live: GET /api/v1/injuries?sport=baseball -> 200 {\"data\":[{\"player\":\"A.J. Puk\",\"player_position\":\"RP\",\"espn_player_id\":\"36201\",...\"status\":\"60-Day-IL\"...}]}. docs.sharpapi.io/openapi.json -> /injuries MISSING.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add an /injuries path to openapi.json (flat {data,...} envelope, sport/league filters) and a doc page. Prior audit flagged it as possibly pre-GA; it is now confirmed live GA, so it should be documented. Confirm tier gate with team (prior audit noted an 'injuries' feature gate, but live returned 200 on the enterprise-grade key — verify whether Free/Hobby get it).", + "locale_scope": "all 4 locales", + "unit": "census-openapi" + }, + { + "title": "Historical-family endpoints (clv, odds/closing, odds/movements, opportunities, summary) missing from OpenAPI spec", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "openapi.json documents none of /historical/clv, /historical/odds/closing, /historical/odds/movements, /historical/opportunities, /historical/opportunities/{id}, /historical/summary.", + "reality": "All six are registered in routes.go (lines 86-91) gated by the 'history' feature (ENTERPRISE only) and confirmed live: /historical/clv -> 200 {data:{date_range,group_by,groups:[...]}}. These are real Enterprise endpoints with zero machine-spec coverage; only two have doc pages (historical-clv, historical-odds-closing per _meta.js).", + "evidence": "routes.go:86-91 (history-gated handlers); live: GET /api/v1/historical/clv?sport=baseball -> 200 {\"data\":{\"date_range\":...,\"groups\":[{\"group_key\":\"sx_bet\",\"total_graded\":872,...}]}}. docs.sharpapi.io/openapi.json -> all /historical/* MISSING.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /historical/{clv,summary,odds/closing,odds/movements,opportunities,opportunities/{id}} to openapi.json under an Enterprise-gated history group; these use the WRAPPED {success,data,meta} envelope (documented exception). At minimum add the four that lack doc pages (summary, odds/movements, opportunities, opportunities/{id}).", + "locale_scope": "all 4 locales", + "unit": "census-openapi" + }, + { + "title": "/odds/closing (Closing Line, Pro+) missing from OpenAPI spec despite having a doc page", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "_meta.js lists 'odds-closing: Closing Line' as a doc page, but openapi.json has no /odds/closing path.", + "reality": "GET /api/v1/odds/closing is registered (routes.go:85, closing_line feature => Pro+) and live (400 'event_id query parameter is required' = real handler validating params). The MDX page exists but the machine spec — what SDK generators and API explorers consume — omits it entirely.", + "evidence": "routes.go:85 (closingHandler, closingGate); live: GET /api/v1/odds/closing?sport=baseball -> 400 {\"error\":{\"code\":\"validation_error\",\"message\":\"event_id query parameter is required\"}}. docs.sharpapi.io/openapi.json -> /odds/closing MISSING.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /odds/closing to openapi.json (Pro+ closing_line gate, required event_id query param, flat paginated envelope) so it matches the existing odds-closing.mdx doc page and is picked up by generated SDKs/explorers.", + "locale_scope": "all 4 locales", + "unit": "census-openapi" + }, + { + "title": "/odds/historical (per-event historical odds, Enterprise) missing from OpenAPI spec", + "severity": "MEDIUM", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "openapi.json has no /odds/historical path.", + "reality": "GET /api/v1/odds/historical is live (400 'sportsbook parameter is required' = real handler) — distinct from /historical/odds/closing. Per prior audit it is Enterprise-gated (history feature). Not in this stale local checkout's routes.go (landed after 1eb8d6b) but confirmed live.", + "evidence": "live: GET /api/v1/odds/historical?event_id=x -> 400 {\"error\":{\"code\":\"validation_error\",\"message\":\"sportsbook parameter is required\"}}. docs.sharpapi.io/openapi.json -> /odds/historical MISSING.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /odds/historical to openapi.json under the Enterprise/history group, clearly distinguished from /historical/odds/closing (per-event OLV/CLV ticks vs date-bucketed closing odds).", + "locale_scope": "all 4 locales", + "unit": "census-openapi" + }, + { + "title": "OpenAPI spec embeds a Subscription-Tiers table with wrong Sharp/Enterprise limits and missing features", + "severity": "MEDIUM", + "category": "tier", + "page": "public/openapi.json (info.description)", + "docs_claim": "info.description Subscription Tiers table: Sharp 'Max Books = All'; Enterprise 'Rate Limit = Custom'; columns only EV/Arb/Middles/Game State (no low_hold/splits/closing_line); Game State shown only in the Enterprise row.", + "reality": "tiers.go: Sharp MaxBooks = 25 (NOT unlimited/All — only Enterprise is -1/unlimited); Enterprise rate limit = 10000/min (NOT 'Custom'). Pro/Sharp also have low_hold, splits, closing_line features (omitted from table). Game State is a $79/mo ADD-ON available to ANY tier (or Enterprise), not an Enterprise-exclusive feature as the table implies; streaming requires the $99 WebSocket add-on which the table doesn't mention at all.", + "evidence": "live: docs.sharpapi.io/openapi.json info.description shows 'Sharp ... All' and 'Enterprise | Custom'; reality per route brief tiers.go — sharp MaxBooks 25, enterprise 10000/min, pro/sharp features include low_hold+splits+closing_line, gamestate=game_state addon.", + "current_status": "still-present", + "confidence": "high", + "fix": "Update the info.description tier table: Sharp Max Books = 25; Enterprise Rate Limit = 10,000/min; add Low Hold / Splits / Closing Line columns (Pro+); reframe Game State as a $79/mo add-on (not Enterprise-only) and add the $99 WebSocket add-on note for streaming.", + "locale_scope": "all 4 locales (machine spec shared)", + "unit": "census-openapi" + }, + { + "title": "Nav separator 'Historical (Sharp+)' should be 'Historical (Enterprise)'", + "severity": "MEDIUM", + "category": "tier", + "page": "content/en/api-reference/_meta.js", + "docs_claim": "_meta.js:74 separator title: 'Historical (Sharp+)' — implies Sharp tier and above can access the historical endpoints.", + "reality": "All /historical/* endpoints are gated by the 'history' feature which is ENTERPRISE-only (routes.go:86-91 historyGate => tierGateMiddleware('history'); per route brief history => ENTERPRISE only). Sharp tier does NOT have history access. The label oversells access to Sharp/Pro customers.", + "evidence": "content/en/api-reference/_meta.js:74 ('title: \"Historical (Sharp+)\"'); routes.go:86-91 (history feature on all /historical/*); brief: history => ENTERPRISE only.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change _meta.js:74 separator title to 'Historical (Enterprise)'. Replicate to de/es/pt-BR _meta.js mirrors.", + "locale_scope": "all 4 locales (each locale has its own api-reference/_meta.js)", + "unit": "census-openapi" + }, + { + "title": "Base /opportunities endpoint absent from OpenAPI spec", + "severity": "LOW", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "openapi.json documents /opportunities/ev, /arbitrage, /middles, /middles/{id}, /middles/summary, /low_hold but not the ungated base /opportunities.", + "reality": "GET /api/v1/opportunities/ is registered with no tier gate (routes.go:102) returning a combined opportunities feed; it is reachable (301 redirect from /opportunities to the trailing-slash route). Minor since each typed sub-endpoint IS documented, but the aggregate base endpoint is undocumented.", + "evidence": "routes.go:102 (apiKeyRoute GET /api/v1/opportunities/, tierFeature=\"\"); live: GET /api/v1/opportunities -> 301; docs.sharpapi.io/openapi.json -> /opportunities MISSING.", + "current_status": "still-present", + "confidence": "medium", + "fix": "Optionally add /opportunities to the spec (or explicitly document that it is the ungated combined feed). Lower priority since the typed sub-endpoints cover most usage.", + "locale_scope": "all 4 locales", + "unit": "census-openapi" + }, + { + "title": "SSE stream path aliases (/stream/odds, /opportunities, /all, /gamestate, /events) absent from spec", + "severity": "LOW", + "category": "missing-endpoint", + "page": "public/openapi.json", + "docs_claim": "openapi.json documents only GET /stream (the SSE root). info.description states 'the SSE endpoint (/stream) is the only stream covered by this document.'", + "reality": "routes.go:104-109 registers five SSE path aliases (/stream/odds, /stream/opportunities, /stream/all, /stream/gamestate, /stream/events/) in addition to /stream. /stream/gamestate is enterprise/addon-gated differently (game_state) than the websocket_addon aliases. The spec covers only the channel-query form via /stream, not the path aliases.", + "evidence": "routes.go:104-109 (sseStreamHandler + 5 ssePathAlias registrations); openapi.json paths -> only /stream present.", + "current_status": "still-present", + "confidence": "medium", + "fix": "Either document the /stream/* path aliases in the spec/SSE doc page or add an explicit note that they are equivalent to /stream?channel=. Note /stream/gamestate carries the game_state gate (not websocket_addon). Low priority since OpenAPI cannot fully express SSE channels anyway.", + "locale_scope": "all 4 locales", + "unit": "census-openapi" + }, + { + "title": "Committed openapi-version.json sidecar version (2.1.0) mismatches spec info.version (3.1.0)", + "severity": "LOW", + "category": "openapi-drift", + "page": "public/openapi-version.json", + "docs_claim": "public/openapi-version.json:2 version = '2.1.0'; public/openapi.json info.version = '3.1.0'. The sidecar exists (issue #233) so consumers can poll it to detect spec changes; stamp-openapi.mjs copies spec.info.version into the sidecar, so they must match.", + "reality": "MITIGATED-IN-PROD: the committed sidecar is a stale build artifact (last committed 2026-05-25, commit 3ca2ecc, when version was 2.1.0; spec bumped to 3.1.0 by 2026-06-02 commit 6667df0 without re-committing the sidecar). HOWEVER the build step regenerates it: live production docs.sharpapi.io/openapi-version.json serves version 3.1.0 / x-generated-at 2026-06-26 / x-commit-sha 4a2b0dd, matching the live spec. So consumers polling the deployed sidecar see the correct 3.1.0; only the in-repo checked-in artifact is inconsistent.", + "evidence": "public/openapi-version.json:2 (\"version\": \"2.1.0\"); openapi.json info.version=3.1.0; git: sidecar last commit 3ca2ecc 2026-05-25, spec last commit 6667df0 2026-06-02; live: docs.sharpapi.io/openapi-version.json -> {\"version\":\"3.1.0\",\"x-generated-at\":\"2026-06-26...\",\"x-commit-sha\":\"4a2b0dd\"}; build script wires stamp-openapi.mjs (package.json build).", + "current_status": "still-present", + "confidence": "high", + "fix": "Either stop committing the generated openapi-version.json (add to .gitignore — it is rebuilt at deploy) or re-run scripts/stamp-openapi.mjs and commit so the checked-in artifact matches. Purely cosmetic for prod since the live sidecar is regenerated, but the in-repo mismatch is misleading to anyone reading the repo or running a non-CI build.", + "locale_scope": "shared machine spec (not locale-scoped)", + "unit": "census-openapi" + }, + { + "title": "Committed spec x-generated-at/x-commit-sha stamps are stale (2026-05-20 / 13f97e3) vs spec's own last edit", + "severity": "LOW", + "category": "openapi-drift", + "page": "public/openapi.json (info.x-generated-at / info.x-commit-sha)", + "docs_claim": "Committed openapi.json info.x-generated-at = '2026-05-20T23:30:42-04:00', info.x-commit-sha = '13f97e3' — the stamp script sets these to the spec file's last git commit.", + "reality": "The committed spec was edited through commit 6667df0 (2026-06-02) — 5 commits after 13f97e3 (2026-05-20) — so the committed stamps are >1 month stale relative to the file's true last edit. As with the sidecar, the build step refreshes them: live spec serves x-generated-at 2026-06-26 / x-commit-sha 4a2b0dd. The committed values are a stale artifact, not what consumers receive. (This staleness in the local file is also what made the local checkout appear to be missing /odds/export & /injuries — those landed in the Go API after this spec/checkout snapshot.)", + "evidence": "openapi.json info.x-commit-sha=13f97e3 (commit dated 2026-05-20); git log -- public/openapi.json last commit 6667df0 2026-06-02; live spec x-generated-at=2026-06-26 / x-commit-sha=4a2b0dd.", + "current_status": "still-present", + "confidence": "high", + "fix": "Same remediation as the sidecar: don't commit stamp-mutated fields, or re-run stamp-openapi.mjs before committing. The deployed spec is fresh, so customer impact is nil — but the committed stamps mislead anyone diffing the repo.", + "locale_scope": "shared machine spec (not locale-scoped)", + "unit": "census-openapi" + } + ], + "refuted": [] +} diff --git a/DOCS_REALITY_AUDIT_2026-06-26_followup.json b/DOCS_REALITY_AUDIT_2026-06-26_followup.json new file mode 100644 index 0000000..0913422 --- /dev/null +++ b/DOCS_REALITY_AUDIT_2026-06-26_followup.json @@ -0,0 +1,1660 @@ +{ + "_annotation_2026_07_06": "SUPERSEDED: /api/v1/odds/export is internal-only since PR #1159 (commit 356d989). routes.go:85 wraps it internalOnly(oddsExportHandler) — customer API key -> 404. All findings below that recommend documenting /odds/export or adding it to public/openapi.json are STALE. The routes.go line number cited (routes.go:71) is also stale — the route is now at line 85 with the internalOnly wrap. Suppress H61 (findings.json) and the census-v2 /odds/export entry (followup.json). Governing decision: DOCS_FIX_PROGRESS_2026-06-26.md line 7. See crew task #2423.", + "gap_findings": [ + { + "title": "EV-calculation cites `threshold` parameter; the real EV filter param is `min_ev` (threshold is a silent no-op)", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/concepts/ev-calculation.mdx", + "docs_claim": "\"SharpAPI's default threshold is 2% EV. Adjust with the `threshold` parameter.\" (line 98)", + "reality": "There is no `threshold` query/JSON parameter anywhere in the filters package. The EV floor is controlled by `min_ev`. A caller passing `threshold=5` gets it silently ignored (not in the known-param set) and the default floor still applies — so the doc's only actionable instruction is wrong and produces no error to alert the user.", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/pkg/filters/parse.go:173 `res.Filters.MinEV = parseFloatPtr(q.Get(\"min_ev\"))`; unknown.go:28 lists `\"min_ev\": true` (no `threshold`); grep for `threshold` in pkg/filters returns only comments, never a parsed key.", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace `threshold` with `min_ev` in the Note callout: \"Adjust with the `min_ev` parameter (EV-percentage floor, e.g. `min_ev=2`).\"", + "locale_scope": "en (replicates in de/es/pt-BR if mirrored)", + "unit": "concepts" + }, + { + "title": "EV-calculation states default threshold is 2% EV; the real default `min_ev` floor is 0.1%", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/en/concepts/ev-calculation.mdx", + "docs_claim": "\"SharpAPI's default threshold is 2% EV.\" (line 98) and the EV Thresholds table marks \"2-5% Good Standard threshold\".", + "reality": "The server default for the EV opportunity floor is `MinEV = 0.1` (i.e. 0.1%, compared against `ev_percentage`), not 2%. A client expecting the API to pre-filter to >=2% will receive opportunities down to ~0.1% and must filter client-side or pass `min_ev=2`.", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/pkg/filters/defaults.go:39 `MinEV: 0.1` in the `Standard` defaults block; sse.go:449 `MinEV: 0.1`; websocket.go:2500 comment \"default 0.1 when nil\".", + "current_status": "still-present", + "confidence": "high", + "fix": "Either correct to \"the default `min_ev` floor is 0.1%\" or, if 2% is the recommended editorial threshold (not the server default), reword to make clear 2% is your suggestion and the API default is 0.1%.", + "locale_scope": "en (replicates ×4)", + "unit": "concepts" + }, + { + "title": "Event-matching de/es/pt-BR show wrong canonical ID format (no `_b{N}` bucket, no date hyphens) — non-EN examples will not match the API", + "severity": "HIGH", + "category": "i18n", + "page": "content/de/concepts/event-matching.mdx (also es, pt-BR)", + "docs_claim": "Format `{league}_{teamA}_{teamB}_{YYYY-MM-DD}` with example IDs `nba_celtics_lakers_20260208` used in the example table, the JSON `id`, the upsert primary-key snippet, and the `/events/{id}/odds` URL.", + "reality": "The real canonical ID always carries the 6-hour start-time bucket suffix and hyphenated date: `nba_celtics_lakers_2026-02-08_b3`. Live API confirms: `mlb_rockies_twins_2026-06-27_b3`, `england_-_t20_blast_..._b2`. A non-EN developer copying these examples as a primary key — or passing `nba_celtics_lakers_20260208` to `/events/{id}/odds` — gets no match / silent doubleheader+cross-day collisions. The EN page was rewritten (commit 9a9af03) but the localized pages were left at the old version.", + "evidence": "live: GET /api/v1/odds?sportsbook=pinnacle&sport=baseball -> event_id `mlb_rockies_twins_2026-06-27_b3`. de/es/pt-BR event-matching.mdx line 16 `Format: {league}_{teamA}_{teamB}_{YYYY-MM-DD}` and lines 23-26/39/83/99 `nba_celtics_lakers_20260208`. EN page line 16-19/26-28 includes `_b{N}` and `2026-02-08_b3`.", + "current_status": "still-present", + "confidence": "high", + "fix": "Re-translate the EN event-matching.mdx into de/es/pt-BR: add the `_b{N}` bucket to the Format line and all example IDs (use `2026-02-08_b3`), and port the four missing sections (Start-time bucket, Doubleheader suffix, Grouping recipe, Live-vs-Pre-Match shared ID).", + "locale_scope": "de, es, pt-BR (NOT en — en is correct)", + "unit": "concepts" + }, + { + "title": "Event-matching de/es/pt-BR missing four EN sections (bucket suffix, doubleheader `_g{N}`, grouping recipe, live/prematch shared-ID)", + "severity": "MEDIUM", + "category": "i18n", + "page": "content/de/concepts/event-matching.mdx (also es, pt-BR)", + "docs_claim": "Localized pages are 141 lines and stop after \"Team Name Normalization\" / \"Using Canonical IDs\"; they omit the `_b{N}` Start-time bucket, the `_g{N}` Doubleheader suffix, the \"Grouping one matchup across IDs\" recipe, and the \"Live vs Pre-Match Share The Same Event ID\" section.", + "reality": "The EN page (216 lines) documents all four as load-bearing behavior of the ID scheme. The localized pages give a materially incomplete and partly contradictory account of how IDs are formed and how to collapse fragmented rows.", + "evidence": "`grep '^#'` de/concepts/event-matching.mdx returns headings only up to \"Wesentliche Eigenschaften\" with no bucket/doubleheader/grouping/live section; EN has \"### Start-time bucket suffix (`_b{N}`)\", \"### Doubleheader suffix (`_g{N}`)\", \"### Grouping one matchup across IDs\", \"## Live vs Pre-Match Share The Same Event ID\". de/es/pt-BR all 141 lines vs EN 216.", + "current_status": "still-present", + "confidence": "high", + "fix": "Port the four missing sections into de/es/pt-BR (same edit as the format fix above).", + "locale_scope": "de, es, pt-BR", + "unit": "concepts" + }, + { + "title": "Live-vs-Pre-Match coverage table lists Novig and Polymarket at Free tier; both require Hobby (free tier whitelist is dk+fd only)", + "severity": "HIGH", + "category": "tier", + "page": "content/en/concepts/live-vs-prematch.mdx", + "docs_claim": "Book Coverage Summary table (lines 128-129): \"Novig ... Free\" and \"Polymarket ... Free\".", + "reality": "Both Novig and Polymarket have `RequiresTier: \"hobby\"`. Moreover the Free tier carries an AllowedBooks whitelist of exactly `{draftkings, fanduel}` — a Free key can NEVER be served Novig or Polymarket and would get a `403 tier_restricted`. The doc tells Free users they can access two books they cannot.", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/sportsbooks.go:56 `\"novig\": {... RequiresTier: \"hobby\"}`; sportsbooks.go:58 `\"polymarket\": {... RequiresTier: \"hobby\"}`; tiers.go:34 free `AllowedBooks: []string{\"draftkings\", \"fanduel\"}`.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the Tier Required column for Novig and Polymarket from \"Free\" to \"Hobby\".", + "locale_scope": "en (replicates ×4 if mirrored)", + "unit": "concepts" + }, + { + "title": "Entity-reference-ids logo CDN host is stale: docs show `cdn.opticodds.com/team-logos/...`; live API serves `cdn.sharpapi.io/teams/...`", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/concepts/entity-reference-ids.mdx", + "docs_claim": "JSON example (lines 28, 39) and field-reference example (line 122) all show `\"logo\": \"https://cdn.opticodds.com/team-logos/baseball/36.png\"`. Line 122 note even hedges \"SharpAPI may re-host the same asset under its own domain in the future.\"", + "reality": "The API already serves logos from SharpAPI's own domain with a different path: `https://cdn.sharpapi.io/teams/baseball/22.png`. The documented host and path segment (`team-logos`) are both wrong. A reader hardcoding/validating against `cdn.opticodds.com` would be surprised.", + "evidence": "live: GET /api/v1/odds?sportsbook=pinnacle&sport=baseball -> home.logo `https://cdn.sharpapi.io/teams/baseball/22.png`, away.logo `https://cdn.sharpapi.io/teams/baseball/8.png`. (Go passes atlas logo through unchanged — endpoints.go:1105 `Logo: ta.logo`; no host rewrite, so the live host is ground truth.)", + "current_status": "still-present", + "confidence": "high", + "fix": "Update all logo examples to `https://cdn.sharpapi.io/teams//.png` and drop the \"may re-host in the future\" caveat (it has happened). Keep the \"treat host as opaque\" guidance.", + "locale_scope": "en (replicates ×4)", + "unit": "concepts" + }, + { + "title": "Odds-formats opening JSON example nests fields under an `odds` object; the API returns them flat (and mixes prefixed/unprefixed names)", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/concepts/odds-formats.mdx", + "docs_claim": "Lines 11-19 show `{ \"odds\": { \"american\": -110, \"decimal\": 1.909, \"odds_probability\": 0.5238 } }` — a nested `odds` object with keys `american`, `decimal`, `odds_probability`.", + "reality": "Odds rows return the three formats as FLAT top-level fields named `odds_american`, `odds_decimal`, `odds_probability`. There is no nested `odds` wrapper on REST odds rows, and the field names are uniformly `odds_`-prefixed — not the mixed `american`/`decimal`/`odds_probability` shown.", + "evidence": "live: odds row keys include flat `odds_american:-154, odds_decimal:1.649, odds_probability:0.6063` (no `odds` object). Code: compact_delta.go:39-41 `json:\"odds_american\"`, `odds_decimal`, `odds_probability`; endpoints.go:110-111.", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the nested example with the real flat shape: `{ \"odds_american\": -110, \"odds_decimal\": 1.909, \"odds_probability\": 0.5238 }`.", + "locale_scope": "en (replicates ×4)", + "unit": "concepts" + }, + { + "title": "Polymarket-resolution JSON example uses invented camelCase field names (`marketType`, `odds`, `trueProbability`) not present on REST rows", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/concepts/polymarket-resolution.mdx", + "docs_claim": "settled_normal example (lines 34-42) shows `\"marketType\": \"binary\", \"odds\": -10000, ... \"trueProbability\": 1.0` alongside the (correct) snake_case `polymarket_resolution`.", + "reality": "REST odds rows use `market_type`, `odds_american`/`odds_decimal`/`odds_probability`; there is no `marketType`, bare `odds`, or `trueProbability` field. The `polymarket_resolution` value/enum itself is correct (settled_normal|voided|disputed|proposed|unknown, snake_case on REST), but the surrounding example fields are not real.", + "evidence": "Enum verified: /tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/main.go:584-587 `settled_normal | voided | disputed | proposed | unknown` and main.go:705 `json:\"polymarket_resolution\"`; api-adapters/adapters/polymarket.py:863-910 same values. Field-name mismatch vs live odds row (market_type/odds_american/odds_probability).", + "current_status": "still-present", + "confidence": "medium", + "fix": "Replace example field names with the real ones (`market_type`, `odds_american`, drop `trueProbability` or use `odds_probability`), keeping `polymarket_resolution` as-is.", + "locale_scope": "en (replicates ×4)", + "unit": "concepts" + }, + { + "title": "Live-vs-Pre-Match JSON example event_id omits the mandatory `_b{N}` bucket suffix (contradicts event-matching page + live)", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/concepts/live-vs-prematch.mdx", + "docs_claim": "Line 38: `\"event_id\": \"nba_cavaliers_celtics_2026-04-18\"` (no `_b{N}` suffix).", + "reality": "Canonical event IDs always end in `_b{N}` (the event-matching page establishes this as part of the format; live API confirms every id carries it). The example is internally inconsistent with the sibling concept page.", + "evidence": "live: every odds row event_id ends `_b{N}` (e.g. `mlb_rockies_twins_2026-06-27_b3`). EN event-matching.mdx line 17-19 documents `_b{N}` as part of `Format`.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add the bucket suffix to the example id, e.g. `nba_cavaliers_celtics_2026-04-18_b3`.", + "locale_scope": "en (replicates ×4)", + "unit": "concepts" + }, + { + "title": "Python account example uses info.key['tier'] / info.limits / info.features.ev — none exist in API response; raises immediately", + "severity": "CRITICAL", + "category": "field-name", + "page": "content/en/sdks/python.mdx", + "docs_claim": "Account example (lines 150-153): info = client.account.me(); print(info.key['tier']); print(info.limits.requests_per_minute); print(f\"EV={info.features.ev}, Arb={info.features.arbitrage}\")", + "reality": "The /api/v1/account response body is {data:{key_id, tier, rate_limit:{requests_per_minute,max_books}, features:[list], streaming:{...}}, updated_at}. There is no top-level 'key' object (tier is at data.tier), no 'limits' object (it's 'rate_limit'), and 'features' is a flat string array (e.g. ['odds','schedule','ev',...]) not an object with .ev/.arbitrage booleans. The Python SDK AccountInfo model (models.py:649 key:dict|None, limits:AccountLimits|None, features:AccountFeatures|None) validates the API's data via model_validate(raw) (client.py:707-709), so against the real response: info.key=None -> info.key['tier'] raises TypeError 'NoneType not subscriptable'; info.limits=None -> info.limits.requests_per_minute raises AttributeError; info.features can't parse a list into AccountFeatures.", + "evidence": "endpoints.go:1667-1681 (account response shape: data.key_id/tier/rate_limit/features-list); sharpapi-python models.py:649-653 (AccountInfo.key/limits/features), 635-647 (AccountLimits/AccountFeatures), client.py:707-709 (account.me does AccountInfo.model_validate(data['data']))", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the account example against the real shape. Either document raw access (e.g. info = client.account.me() then read info.tier / info.rate_limit.requests_per_minute and treat features as a list: 'ev' in info.features) AND fix the SDK AccountInfo model to map key_id/tier/rate_limit and a features list — or at minimum stop showing info.key['tier'], info.limits.*, info.features.ev as working code. locale_scope: replicates in de/es/pt-BR mirrors.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "Python middles example uses mid.side1.odds.american — API emits flat odds_american (no nested odds object); SDK model parse fails", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/sdks/python.mdx", + "docs_claim": "Middles example (lines 121-122): print(f\" {mid.side1.book}: {mid.side1.selection} {mid.side1.line} @ {mid.side1.odds.american}\") and mid.side2.odds.american", + "reality": "The middles transform emits FLAT side fields: side1/side2 = {book, selection, line, odds_american, odds_decimal, odds_probability, fair_probability, stake_percent, ...}. There is NO nested 'odds' object, so .odds.american does not exist. The Python SDK MiddleSide model (models.py:375-384) declares odds: OddsValue as a REQUIRED nested field with required OddsValue.american (models.py:17-22) — so parsing a real middles response (which has odds_american, not odds) fails Pydantic validation for the side, breaking client.middles.get().", + "evidence": "transforms.go:1525-1537 (side1Map flat: book/selection/line/odds_american/odds_decimal/odds_probability/fair_probability/stake_percent); sharpapi-python models.py:375-384 (MiddleSide.odds: OddsValue required), 17-22 (OddsValue.american/decimal/probability required)", + "current_status": "still-present", + "confidence": "high", + "fix": "Document flat access: mid.side1.odds_american (and odds_decimal/odds_probability). Fix the SDK MiddleSide model to flat odds_american/odds_decimal/odds_probability fields (matching the wire) rather than a nested OddsValue. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "TypeScript LowHoldSide interface models nested odds{american,decimal,implied_probability,fair_probability} — API emits flat odds_american/odds_decimal/odds_probability", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/sdks/typescript.mdx", + "docs_claim": "interface LowHoldSide (lines 166-177): odds: { american, decimal, implied_probability, fair_probability }", + "reality": "The low_hold transform emits each side FLAT: {selection, books, line, odds_american, odds_decimal, odds_probability, fair_probability, selection_id, market_id, external_event_id}. There is no nested 'odds' object, and the implied-probability key is 'odds_probability', not 'implied_probability'. A consumer typing LowHoldOpportunity.side1.odds.implied_probability against real data gets undefined (wrong nesting + wrong key).", + "evidence": "transforms.go:1660-1670 (buildSide flat: selection/books/line/odds_american/odds_decimal/odds_probability/fair_probability)", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the LowHoldSide interface to flat fields: selection, books, line, odds_american, odds_decimal, odds_probability, fair_probability (drop the nested odds object; rename implied_probability -> odds_probability). Same flat shape applies to middle sides. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "EV examples use confidence_score — API emits 'confidence' (int); the field is never populated", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/sdks/python.mdx", + "docs_claim": "python.mdx lines 44 & 87: opp.confidence_score (Confidence: {opp.confidence_score}/100); typescript.mdx EVOpportunity interface line 118: confidence_score: number; staleness interface also implies it.", + "reality": "transformEV emits the key 'confidence' (an int via getInt(raw,'confidence')) — there is no 'confidence_score' field in the EV wire payload. The Python SDK EVOpportunity model has confidence_score with NO alias to 'confidence' (models.py:275), so opp.confidence_score is always None. (The lower LowHoldOpportunity model uses 'confidence', models.py:471, matching its own wire field.) The TS SDK's actual EVOpportunity interface has neither confidence nor confidence_score (camelCase model), so the doc's hand-written snake_case interface inventing confidence_score is doubly inaccurate.", + "evidence": "transforms.go:1323 (\"confidence\": getInt(raw, \"confidence\")); sharpapi-python models.py:275 (confidence_score: float|None=None, no alias); sharpapi-ts src/index.ts:336-355 (EVOpportunity has no confidence field at all)", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace confidence_score with confidence (int 0-100) in both the Python examples and the TS inline EVOpportunity interface, and fix the Python SDK model field name (or add alias 'confidence'). locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "TypeScript inline EVOpportunity interface uses 'game' and 'market' — API emits 'event_name' and 'market_type'", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/sdks/typescript.mdx", + "docs_claim": "EVOpportunity interface (lines 106-124): selection, market: string, sportsbook, game: string (and later examples use opp.selection / opp.ev_percentage / opp.sportsbook)", + "reality": "transformEV emits event_name (not game) and market_type (not market). The raw EV wire payload has no 'game' or 'market' keys. A consumer using opp.game / opp.market against real SSE/REST EV data gets undefined. (The Python SDK papered over this with validation aliases game->event_name and market->market_type, models.py:259-261, but the TS doc's hand-written raw-JSON interface has no such remap, so it directly mis-describes the wire.)", + "evidence": "transforms.go:1293 (\"event_name\": eventName), 1298 (\"market_type\": marketType); no \"game\"/\"market\" keys in transformEV out map (transforms.go:1290-1356)", + "current_status": "still-present", + "confidence": "high", + "fix": "In the TS inline EVOpportunity interface, rename game -> event_name and market -> market_type to match the wire payload. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "MCP doc: Sharp tier shown as 'All 32' sportsbooks / find_low_hold listed 'All' tier — Sharp caps at 25 books and low_hold is Pro+", + "severity": "HIGH", + "category": "tier", + "page": "content/en/sdks/mcp.mdx", + "docs_claim": "Sportsbook Access by Tier table: Sharp = 'All 32' / 'Every sportsbook', Enterprise = 'All 32'. Tools table: find_low_hold min tier = 'All'; find_arbitrage = 'Hobby'.", + "reality": "Sharp tier MaxBooks = 25 (tiers.go:51, explicit comment: 'Choose any 25', not unlimited/all). Only enterprise is unlimited (MaxBooks: -1, tiers.go:58). low_hold is a Pro+ feature: requiredTierForFeature('low_hold') resolves to 'pro' (first tier carrying it is pro, tiers.go:43), and the MCP find_low_hold tool is wrapped in tierGateMiddleware('low_hold') (mcp.go:297-300) -> 403 tier_restricted for free/hobby. The MCP doc's own Opportunities table says find_middles=Pro and find_ev=Pro but lists find_low_hold='All', which is inconsistent with the gate.", + "evidence": "tiers.go:51 (sharp MaxBooks:25 with 'Choose any 25' comment), tiers.go:58 (enterprise MaxBooks:-1), tiers.go:43 (pro Features includes low_hold = first tier), mcp.go:297-300 (find_low_hold uses tierGateMiddleware(\"low_hold\")), endpoints.go account shows max_books per tier", + "current_status": "still-present", + "confidence": "high", + "fix": "Change Sharp to '25' (Choose any 25), not 'All 32'. Mark Enterprise as Unlimited (all books). Change find_low_hold min tier from 'All' to 'Pro'. The Python doc's Low Hold section (no tier label) should also be marked Pro+. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "Python doc calls client.events.search('Lakers') — no search() method exists on the events resource", + "severity": "MEDIUM", + "category": "example-breaks", + "page": "content/en/sdks/python.mdx", + "docs_claim": "Reference Data example line 144: client.events.search(\"Lakers\")", + "reality": "The Python SDK _EventsResource exposes only .list() and .get() (client.py:661-697). There is no .search() method, so client.events.search('Lakers') raises AttributeError. Event search is done via client.events.list(q=...) / the underlying eventsHandler 'q' param.", + "evidence": "sharpapi-python client.py:661 (_EventsResource), 665 (def list), 687 (def get) — no def search; grep 'def search' returns nothing in client.py", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace client.events.search(\"Lakers\") with client.events.list(q=\"Lakers\") (the supported search path), or add a search() alias to the SDK. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "Python doc claims 'Requires Python 3.9+' but the package requires >=3.10", + "severity": "MEDIUM", + "category": "conceptual", + "page": "content/en/sdks/python.mdx", + "docs_claim": "Installation line 23: 'Requires Python 3.9+.' (description frontmatter also implies 3.9 baseline)", + "reality": "pyproject.toml sets requires-python = '>=3.10' (and the SDK source uses 3.10+ syntax like 'str | None' union types). pip install on Python 3.9 will fail / the package is not installable there.", + "evidence": "sharpapi-python pyproject.toml:11 (requires-python = \">=3.10\"); models.py uses PEP 604 'X | None' unions throughout", + "current_status": "still-present", + "confidence": "high", + "fix": "Change 'Requires Python 3.9+' to 'Requires Python 3.10+' to match pyproject.toml requires-python. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "other-languages doc: OpenAPI spec described as '34 endpoints, 27 schemas' — actual public/openapi.json has 35 paths (36 operations) and 29 schemas", + "severity": "LOW", + "category": "openapi-drift", + "page": "content/en/sdks/other-languages.mdx", + "docs_claim": "OpenAPI Spec section line 21: 'This is a standard OpenAPI 3.1.0 spec with 34 endpoints, 27 schemas, and full request/response examples.'", + "reality": "public/openapi.json (the served spec, openapi: 3.1.0 — that part matches) contains 35 paths / 36 operations and 29 component schemas. The '34 endpoints / 27 schemas' counts are stale.", + "evidence": "public/openapi.json parsed: openapi=3.1.0, paths=35, operations=36, components.schemas=29 (live count of /root/sharpapi.io/docs.sharpapi.io/public/openapi.json)", + "current_status": "still-present", + "confidence": "high", + "fix": "Update the counts to match the current spec (e.g. '35 endpoints, 29 schemas') or make the sentence count-agnostic ('full OpenAPI 3.1.0 spec covering all endpoints and schemas'). Note these counts will drift again — prefer not hard-coding them. locale_scope: replicates ×4.", + "locale_scope": "en + de/es/pt-BR (×4)", + "unit": "sdks" + }, + { + "title": "Arb scanner SSE handler reads a `data` wrapper that does not exist (data['data']['opportunities'] KeyErrors on every event)", + "severity": "CRITICAL", + "category": "envelope", + "page": "content/en/examples/arbitrage-scanner.mdx", + "docs_claim": "Lines 52 & 59: `opps = data['data']['opportunities']` and `for opp in data['data']['opportunities']:` — assumes SSE event payloads are shaped `{\"data\": {\"opportunities\": [...]}}`.", + "reality": "SSE opportunity frames have NO `data` wrapper and NO `opportunities` key in a `snapshot`. The `:detected`/`:expired` frame is `{\"opportunities\": [...], \"count\": N, \"type\": \"arbitrage\"}` (top-level `opportunities`). The initial `snapshot` frame is `{\"arbitrage\": [...], \"count\":N, \"total\":N, \"offset\":N, \"has_more\":bool}` (keyed by opType, no `opportunities` at all). `data['data']` raises KeyError in both handlers.", + "evidence": "gofile: streaming.go:2604-2614 (detected payload `{\"opportunities\":detected,\"count\":...,\"type\":opType}` via sseClientTrySend); sse.go:1395-1407 (snapshot payload `{opType:chunk,\"count\",\"total\",\"offset\",\"has_more\"}` via writeSSE event \"snapshot\"). live: GET /api/v1/opportunities/arbitrage TOP KEYS ['data','pagination','updated_at'] — but that REST envelope is unrelated to the SSE frame, which carries the keys above with no outer `data`.", + "current_status": "still-present", + "confidence": "high", + "fix": "For `:detected` frames read `json.loads(event.data)['opportunities']` (top-level). For the initial frame, see the next finding — the event is `snapshot`, and its list lives under the `arbitrage` key, not `data.opportunities`.", + "locale_scope": "en + de/es/pt-BR (all 3 mirrors carry identical code — verified data['data'] / event names present in each)", + "unit": "examples" + }, + { + "title": "Arb scanner listens for SSE event `arbitrage` but the stream emits `arb:detected`", + "severity": "CRITICAL", + "category": "asyncapi-drift", + "page": "content/en/examples/arbitrage-scanner.mdx", + "docs_claim": "Line 48: `elif event.event == 'arbitrage':` registers the arbitrage delta handler on an SSE event named `arbitrage`.", + "reality": "The opportunities channel emits arbitrage deltas under event name `arb:detected` (and `arb:expired`). The op-type→event-prefix map turns `arbitrage` into prefix `arb`, so the event is `arb:detected`. There is no `arbitrage` event — the handler never fires and no arbs are ever processed.", + "evidence": "gofile: streaming.go:2717-2730 `sseOpTypeToEventPrefix` returns \"arb\" for opType \"arbitrage\"; streaming.go:2604+2612 builds `eventPrefix+\":detected\"` → `arb:detected`. (Contrast: EV uses prefix \"ev\" → `ev:detected`, which the value-betting doc gets right.)", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the listener to event `arb:detected` (and handle `arb:expired` for de-dup cleanup). Keep the value-betting `ev:detected` name as-is — that one is correct.", + "locale_scope": "en + de/es/pt-BR (de line 48 confirmed `event == 'arbitrage'`)", + "unit": "examples" + }, + { + "title": "Arb scanner listens for initial-state event `initial` but the stream emits `snapshot`", + "severity": "CRITICAL", + "category": "asyncapi-drift", + "page": "content/en/examples/arbitrage-scanner.mdx", + "docs_claim": "Line 46: `if event.event == 'initial':` plus `_handle_initial` (lines 51-56) reading `data['data']['opportunities']` — assumes a connect-time event named `initial` carrying `{data:{opportunities:[...]}}`.", + "reality": "The connect-time opportunity dump is emitted as SSE event `snapshot`, chunked, with payload `{\"arbitrage\":[...],\"count\":N,\"total\":N,\"offset\":N,\"has_more\":bool}`. There is no `initial` event and no `data.opportunities` nesting, so `_handle_initial` never runs (and would KeyError if it did).", + "evidence": "gofile: sse.go:1326-1407 `sendSSEOpportunitiesSnapshot` marshals `{opType:chunk, count, total, offset, has_more}` and calls `writeSSE(w,flusher,\"snapshot\",data)`. The connect lifecycle events are `connected`/`source_health`/`filter_warning`/`snapshot`/`snapshot:complete` (sse.go:716,725,736,1407,820-878) — never `initial`.", + "current_status": "still-present", + "confidence": "high", + "fix": "Listen for event `snapshot` and read the list from the op-type key (`data['arbitrage']`), iterating chunks until `has_more` is false; treat `snapshot:complete` as end-of-initial.", + "locale_scope": "en + de/es/pt-BR (de line 46 confirmed `event == 'initial'`)", + "unit": "examples" + }, + { + "title": "Value-betting bot reads JSON.parse(e.data).data.opportunities — no `data` wrapper on the ev:detected frame, throws on iterate", + "severity": "CRITICAL", + "category": "envelope", + "page": "content/en/examples/value-betting.mdx", + "docs_claim": "Lines 49 & 99: `const { opportunities } = JSON.parse(e.data).data;` then `for (const opp of opportunities)` — assumes the `ev:detected` frame is `{\"data\":{\"opportunities\":[...]}}`.", + "reality": "The `ev:detected` frame is `{\"opportunities\":[...],\"count\":N,\"type\":\"ev\"}` with NO `data` wrapper. `JSON.parse(e.data).data` is `undefined`, so `const { opportunities } = undefined` throws TypeError; even with optional chaining `opportunities` would be undefined and `for...of` throws.", + "evidence": "gofile: streaming.go:2604-2612 detected payload `{\"opportunities\":detected,\"count\":len,\"type\":opType}` emitted as `ev:detected`. The event NAME `ev:detected` is correct (streaming.go:2718-2722 prefix \"ev\"); only the `.data` nesting is wrong.", + "current_status": "still-present", + "confidence": "high", + "fix": "Read top-level: `const { opportunities } = JSON.parse(e.data);` (drop `.data`). Optionally also handle the connect-time `snapshot` event whose payload is `{ev:[...],count,total,offset,has_more}`.", + "locale_scope": "en + de/es/pt-BR (mirrors carry identical `JSON.parse(e.data).data` on lines 49 & 99)", + "unit": "examples" + }, + { + "title": "Value-betting bot reads opp.game — EV opportunities have no `game` field (it is event_name)", + "severity": "HIGH", + "category": "field-name", + "page": "content/en/examples/value-betting.mdx", + "docs_claim": "Lines 67 & 128: `.setDescription(`**${opp.game}**`)` and Telegram `*${opp.game}*` — read a `game` field off each EV opportunity.", + "reality": "EV opportunities expose the matchup as `event_name` (e.g. \"Away @ Home\"), plus `event_id`/`market_type`. There is no `game` field; `game` is a raw upstream input that transformEV does not emit, so `opp.game` is `undefined` and every alert shows `**undefined**`.", + "evidence": "gofile: transforms.go:1290-1294 sets `event_name` (and event_id at :1291); no `game` key in transformEV output map. live: GET /api/v1/opportunities/ev item keys include event_id, event_name, market_type, confidence — and NOT `game`.", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace `opp.game` with `opp.event_name` (both line 67 and line 128).", + "locale_scope": "en + de/es/pt-BR (all mirrors use `opp.game` on lines 67 & 128)", + "unit": "examples" + }, + { + "title": "Value-betting bot calls opp.kelly_percent.toFixed(2) — kelly_percent can be null (suspended opps), throwing TypeError", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/examples/value-betting.mdx", + "docs_claim": "Lines 73 & 133: `${opp.kelly_percent.toFixed(2)}%` — assumes `kelly_percent` is always a number.", + "reality": "transformEV emits `kelly_percent` as null for suspended opportunities (and as a non-number `interface{}` in other edge cases). On a suspended EV row, `opp.kelly_percent` is `null` and `.toFixed(2)` throws `TypeError: Cannot read properties of null`. The same null-on-suspend treatment also applies to `no_vig_odds` (line 74), which the doc interpolates without a guard.", + "evidence": "gofile: transforms.go:1397-1409 the is_suspended branch sets out[\"kelly_percent\"]=nil, out[\"no_vig_odds\"]=nil, out[\"ev_percentage\"]=nil; and kelly_percent is computed as `interface{}` that is only set to a number under specific conditions (transforms.go:1235-1246). live: EV item keys include kelly_percent / no_vig_odds (nullable).", + "current_status": "still-present", + "confidence": "high", + "fix": "Guard the format, e.g. `${(opp.kelly_percent ?? 0).toFixed(2)}%`, and skip/grey suspended opps (`if (opp.is_suspended) return;`). Apply the same nil-guard to `no_vig_odds`/`ev_percentage` interpolation.", + "locale_scope": "en + de/es/pt-BR (mirrors use kelly_percent.toFixed on lines 73 & 133)", + "unit": "examples" + }, + { + "title": "WebSocket close code 4029 'Too many connections' does not exist in the server", + "severity": "CRITICAL", + "category": "errcode", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "Close Codes table (L161): '4029 | Too many connections | Close unused connections first'.", + "reality": "No 4029 close code is emitted anywhere in the WS server. grep for '4029' across all *.go finds only atlas_teams.json data, never a close code. The only WS close codes the server ever sends are 1000, 1009 (client cap), 1011, 1012, 4001 (auth-failed/displaced), 4003 (entitlements changed). When the per-key concurrent-stream cap (default 1) is exceeded, the OLDER socket is displaced with close code 4001 'displaced by newer session'; a brand-new connect that cannot acquire a slot is rejected with HTTP 429 (TooManyStreams) BEFORE the WebSocket upgrade, not with any 4xxx close code. A client written to branch on 4029 will never see it.", + "evidence": "websocket.go:156 wsCloseAuthFailed=4001; websocket.go:164 wsCloseEntitlementsChanged=4003; websocket.go:3434 FormatCloseMessage(4001,'displaced by newer session'); websocket.go:3053-3068 slot==nil -> HTTP 429 errcodes.TooManyStreams (pre-upgrade, not a close code); grep '4029' *.go pkg/ -> no close-code hit.", + "current_status": "still-present", + "confidence": "high", + "fix": "Remove the 4029 row from the Close Codes table. Replace with the real over-limit behavior: a second connection on the same key DISPLACES the older one (older socket receives close code 4001 'displaced by newer session'); a connection that cannot get a slot is rejected pre-upgrade with HTTP 429 (code too_many_streams, Retry-After 30). There is no 'too many connections' WS close code.", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "Close code 4003 mislabeled 'No streaming access / Purchase WebSocket add-on'", + "severity": "HIGH", + "category": "errcode", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "Close Codes table (L160): '4003 | No streaming access | Purchase WebSocket add-on'.", + "reality": "4003 is NOT the no-streaming-access code. A connect with no streaming entitlement is rejected BEFORE the WebSocket upgrade with HTTP 403 {code: tier_restricted, message: 'WebSocket streaming requires the WebSocket add-on or Enterprise tier', docs: https://sharpapi.io/pricing} — the socket never opens, so no close code is involved. The 4003 close code (wsCloseEntitlementsChanged) is emitted only MID-SESSION, by the #990 re-auth watcher, when an already-connected key's entitlements drop below its connect-time grant (downgrade / cancel / add-on loss / revoke). Its correct meaning is 'entitlements changed — reconnect with fresh auth', not 'purchase the add-on'.", + "evidence": "websocket.go:3032-3043 no-streaming-access -> w.WriteHeader(403) errcodes.TierRestricted (pre-upgrade HTTP, not a close); websocket.go:158-164 comment 'Close code sent when the periodic re-authorization watcher (#990) detects that a connected key's entitlements dropped below its connect-time grant'; websocket.go:3460-3471 closeForEntitlement -> setCloseCode(4003) + FormatCloseMessage(4003, reasonText).", + "current_status": "still-present", + "confidence": "high", + "fix": "Redefine 4003 as 'Entitlements changed mid-session (downgrade / cancel / add-on loss / key revoked)' with action 'Reconnect and re-authenticate with current credentials.' Document that a connect WITHOUT streaming access is rejected pre-upgrade as HTTP 403 (tier_restricted), not a WS close code.", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "min_ev default documented as 2.0; real effective default is 0.1 (and WS applies no default at all)", + "severity": "HIGH", + "category": "param-noop", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "L47: 'Threshold filters: min_ev (default 2.0) ... filter out low-value opportunities server-side.'", + "reality": "The standard/effective min_ev floor is 0.1, not 2.0. pkg/filters/defaults.go:39 Standard.MinEV=0.1 and the EV engine hard floor is MinEVPercent=0.1 (pkg/evengine/types.go:83). On the WebSocket path specifically, when min_ev is omitted the filter pointer stays nil and the matcher applies NO threshold at all (websocket.go:2760 'if c.filters.MinEV != nil'), so a client that doesn't pass min_ev receives EV opportunities down to ~0.1% (engine floor), not 2.0%. (The SSE path explicitly defaults minEV=0.1 — sse.go:449,471-473 — still 0.1, never 2.0.) The struct comment even says 'default 0.1 when nil' (websocket.go:2500).", + "evidence": "pkg/filters/defaults.go:39 MinEV:0.1; pkg/evengine/types.go:83 MinEVPercent=0.1; websocket.go:2500 'MinEV *float64 // ... (default 0.1 when nil)'; websocket.go:2760-2765 filter only applied when !=nil; sse.go:449 MinEV:0.1.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change 'min_ev (default 2.0)' to 'min_ev (default 0.1)'. Optionally note that omitting min_ev streams every opportunity at or above the engine floor of 0.1%, so set min_ev explicitly to reduce volume.", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "WebSocket replay buffer documented as 2 minutes; actual default is ~5 minutes", + "severity": "MEDIUM", + "category": "conceptual", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "L142 code comment '2-min replay buffer' and L150 'The server keeps a 2-minute replay buffer.'", + "reality": "The WebSocket replay buffer default TTL is 300s (~5 minutes), not 2 minutes. wsReplayTTLDefault=300*time.Second (replay.go:96) and the source comment explicitly says 'coverage TTL-bound (~5 min) at typical load' (replay.go:92). The 2-minute (120s) window is the SSE replay buffer (replay.go:83 sseReplayTTL=120s) — a different transport. Understating the WS window is conservative (clients won't lose data) but causes unnecessary full-snapshot fallbacks if a client discards its from_seq checkpoint after only 2 min.", + "evidence": "replay.go:96 wsReplayTTLDefault=300*time.Second; replay.go:92 '...coverage TTL-bound (~5 min)...'; replay.go:83 sseReplayTTL=120*time.Second (SSE, not WS); env-overridable via WS_REPLAY_TTL_SECONDS (replay.go:135).", + "current_status": "still-present", + "confidence": "high", + "fix": "Update both the L142 comment and L150 callout to '~5-minute replay buffer' (default 300s, env-overridable). Reserve '2-minute buffer' for SSE docs only.", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "'subscribed' message listed as automatic step 2 of the connect flow; only sent in reply to a subscribe message", + "severity": "MEDIUM", + "category": "asyncapi-drift", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "Message Flow (L103-107): 'On connect you receive messages in order: 1. connected ... 2. subscribed -- Confirmation of active channels and filters 3. opportunities_snapshot ... 4. initial ... 5. snapshot:complete'.", + "reality": "A client that connects with channels supplied in the URL query string does NOT receive a 'subscribed' frame. The connect dispatch chain (dispatchPostConnect) sends only connected -> opportunities_snapshot/initial -> snapshot:complete (websocket.go:3348 sendConnectedMessage then sendInitialData). 'subscribed' is emitted ONLY inside handleSubscribe, i.e. in response to a client-sent {type:'subscribe'} message (websocket.go:4394-4403). Listing it as automatic step 2 leads clients to await a frame that never arrives on URL-param connects.", + "evidence": "websocket.go:3348 dispatchPostConnect -> sendConnectedMessage (no 'subscribed'); websocket.go:4337 handleSubscribe is the only emitter; websocket.go:4395 '\"type\": \"subscribed\"' inside handleSubscribe loop over msg.Channels.", + "current_status": "still-present", + "confidence": "high", + "fix": "Move 'subscribed' out of the on-connect ordered list. Note it is sent once per channel ONLY in response to a {type:'subscribe'} message. The actual connect order is: connected -> opportunities_snapshot (per opp channel) -> initial (if odds subscribed) -> snapshot:complete.", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "Close code 4001 described only as 'Bad or missing API key'; omits displacement (its dominant meaning) and is wrong for URL-token connects", + "severity": "MEDIUM", + "category": "errcode", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "Close Codes table (L159): '4001 | Bad or missing API key | Fix your API key'.", + "reality": "4001 is dual-meaning and the docs give the less common half. (1) For the URL-token connect shown in the page's own Quick Start (?api_key=YOUR_KEY), a bad/missing key is rejected with HTTP 401 (invalid_api_key / missing_api_key) BEFORE the upgrade — the WebSocket never opens, so the client never sees a 4001 close. (2) 4001 IS sent as a close code for newer-wins displacement ('displaced by newer session') and for v1.5 deferred-auth failures/timeouts. The single-connection.mdx page (L167) correctly documents the displacement meaning; this table contradicts it.", + "evidence": "websocket.go:2999-3008 bad URL key -> HTTP 401 invalid_api_key (pre-upgrade); websocket.go:3014-3024 v1 missing key -> HTTP 401 missing_api_key; websocket.go:3434 FormatCloseMessage(4001,'displaced by newer session'); websocket.go:4170-4175 v1.5 path closes 4001 'too many streams'; websocket.go:3755-3756 v1.5 auth timeout -> 4001.", + "current_status": "still-present", + "confidence": "high", + "fix": "Rewrite the 4001 row: 'Auth failure (v1.5 deferred-auth only) OR connection displaced by a newer session on the same key.' Add a note that for URL-token connects (?api_key=) an invalid/missing key returns HTTP 401 before the upgrade, not a 4001 close. Align with single-connection.mdx L167.", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "Quick Start comment shows wrong WebSocket 'connected' welcome text", + "severity": "LOW", + "category": "example-breaks", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "L57: 'console.log(msg.message); // \"Welcome to SharpAPI real-time odds stream\"'.", + "reality": "The WebSocket connected ack's message field is 'Welcome to SharpAPI WebSocket' (websocket.go:5861). 'Welcome to SharpAPI real-time odds stream' is not the string emitted by the WS handler. Cosmetic — no client logic should depend on the welcome text — but the inline expectation comment is inaccurate.", + "evidence": "websocket.go:5861 '\"message\": \"Welcome to SharpAPI WebSocket\"'; grep 'Welcome to SharpAPI' *.go -> single hit, websocket.go:5861.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change the comment to // \"Welcome to SharpAPI WebSocket\" (or drop the literal entirely since welcome text is not part of the contract).", + "locale_scope": "en + de/es/pt-BR mirrors (×4)", + "unit": "streaming-guides" + }, + { + "title": "min_profit scope incomplete: also filters middles, not just arbitrage", + "severity": "LOW", + "category": "param-noop", + "page": "content/en/streaming/websocket.mdx", + "docs_claim": "L47: 'min_profit (default 0.5, applies to arbitrage only, not low-hold)'.", + "reality": "min_profit is applied to arbitrage (via profit_percent) AND to middles (via expected_value) in the opportunity matcher. The 'not low-hold' part is correct (low-hold items carry neither field in this matcher), but 'applies to arbitrage only' omits middles. The struct comment itself says 'minimum profit % for arb/middles/low_hold opportunities'.", + "evidence": "websocket.go:2767-2780 'MinProfit: apply to arb (profit_percent) and middles (expected_value)' — filters when item['profit_percent']:expired', seq, global_seq, expired:[...]}` — `expired` is a TOP-LEVEL field and there is no `data` key at all.", + "evidence": "websocket.go:1528-1532 — msg = {\"type\": eventPrefix+\":expired\", \"seq\": seq, \"global_seq\": ..., \"expired\": expiredIDs}. No `data` wrapper.", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace the `data: {$ref ExpiredIds}` ref in all four *Expired messages with a top-level `expired: {type: array, items: {type: string}}` property (plus the ServerEnvelope allOf). Delete or repurpose the ExpiredIds schema.", + "unit": "asyncapi" + }, + { + "title": "opportunities_snapshot nests channel arrays under `data`, not at top level", + "severity": "HIGH", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "OpportunitiesSnapshot places `ev`/`arbitrage`/`middles`/`low_hold` arrays as TOP-LEVEL keys alongside `type`/`chunk`/`totalChunks` (asyncapi.yaml lines 293-300).", + "reality": "The frame is `{type:'opportunities_snapshot', data:{:[...]}}` — the per-channel array is nested one level deep under `data`, and only ONE opType key is present per frame (one message per subscribed channel). There are no `chunk`/`totalChunks` fields on this frame.", + "evidence": "websocket.go:5399-5404 — map{\"type\":\"opportunities_snapshot\", \"data\": map{opType: transformed[lo:hi]}}.", + "current_status": "still-present", + "confidence": "high", + "fix": "Model the payload as `data: {type: object, properties: {ev, arbitrage, middles, low_hold: array}}` (single key populated per frame). Remove the top-level ev/arbitrage/middles/low_hold props and the `chunk`/`totalChunks` props (chunking is by row count into separate frames, not signaled with chunk fields).", + "unit": "asyncapi" + }, + { + "title": "initial odds snapshot uses `book`, not `source`", + "severity": "HIGH", + "category": "field-name", + "page": "public/asyncapi.yaml", + "docs_claim": "Initial message has required fields `source` (Sportsbook ID) and `data`, plus optional `count` (asyncapi.yaml lines 311-319).", + "reality": "The initial snapshot frame uses key `book` for the sportsbook id and has NO `count` field. It is `{type:'initial', book:, data:[...]}`.", + "evidence": "websocket.go:5541-5545 — map{\"type\":\"initial\", \"book\": book, \"data\": bookOdds[lo:hi]}. (Contrast odds:update which DOES use `source`+`count` at 1102-1109.)", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename the Initial message's `source` property to `book`, mark `book`+`data` required, and drop the `count` property (not emitted on initial frames).", + "unit": "asyncapi" + }, + { + "title": "snapshot:complete fields wrong: emits `total_odds`/`mode`, not `resumed`/`progressive`", + "severity": "MEDIUM", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "SnapshotComplete carries `books` (array), `resumed` (boolean), `progressive` (boolean) (asyncapi.yaml lines 330-334).", + "reality": "Normal path emits `{type:'snapshot:complete', books:[...], total_odds:}`. Resume path emits a DIFFERENT shape `{type:'snapshot:complete', mode:'resume', replayed_count, skipped_count, last_seq, gap_detected?}` (no `books`). Neither `resumed` nor `progressive` is ever emitted.", + "evidence": "websocket.go:5556-5559 (normal: type/books/total_odds); websocket.go:4752-4764 (resume: type/mode/replayed_count/skipped_count/last_seq/gap_detected).", + "current_status": "still-present", + "confidence": "high", + "fix": "Replace `resumed`/`progressive` with `total_odds` (int) for the normal frame and add a `mode: {enum: [resume]}` variant carrying `replayed_count`, `skipped_count`, `last_seq`, `gap_detected`. The resume variant omits `books`.", + "unit": "asyncapi" + }, + { + "title": "global_seq is a STRING on the wire, spec types it as integer", + "severity": "HIGH", + "category": "field-name", + "page": "public/asyncapi.yaml", + "docs_claim": "ServerEnvelope.global_seq is `type: integer` (line 533-535); Connected.global_seq is `type: integer` (lines 248-252).", + "reality": "global_seq is serialized as a STRING via strconv.FormatInt everywhere (the code deliberately stringifies it for JS >2^53 precision safety). Only `seq` is an integer.", + "evidence": "websocket.go:1512,1531,1969,1989,2241,5851 — \"global_seq\": strconv.FormatInt(seq, 10). Code comment at 2229-2233: '`seq` (int)' and '`global_seq` (string)'.", + "current_status": "still-present", + "confidence": "high", + "fix": "Change global_seq to `type: string` in ServerEnvelope and Connected. Keep `seq` as integer. Note in the description that global_seq is string-encoded to preserve precision past 2^53.", + "unit": "asyncapi" + }, + { + "title": "Spec channel enum omits gamestate, closing_line, all, steam, opportunities", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "public/asyncapi.yaml", + "docs_claim": "The only documented channels are `[ev, arbitrage, middles, low_hold, odds]` (Subscribe enum 190; channel description 77-78; Connected/Subscribed channels enums 246/269).", + "reality": "The server also accepts/serves `gamestate` (add-on/enterprise), `closing_line` (Pro+ feature), `steam` (Pro+, in-process), the `opportunities` rollup alias (expands to ev+arbitrage+middles+low_hold), and `all` (wildcard). Each produces frames the spec never models (gamestate:update/gamestate:removed/gamestate:snapshot, closing_line:captured, steam:detected/steam:expired).", + "evidence": "websocket.go:2325 comment lists odds/opportunities/gamestate/all; channels referenced: gamestate 1887/4355, closing_line 2017/2022, steam 1363/1376/2562, all 692/2510/2532, opportunities alias 4420-4427. Frames: gamestate:update 1967, gamestate:removed 1987, gamestate:snapshot 5620, closing_line:captured 2057. sampleChannelSubscribers gauge at 480-491 enumerates odds/opportunities/ev/arbitrage/middles/low_hold/gamestate/closing_line/all.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add gamestate/closing_line/all/opportunities (and steam) to the channel enums, document the `all` wildcard and `opportunities` rollup-alias semantics, and add message schemas for gamestate:update/gamestate:removed/gamestate:snapshot, closing_line:captured, and steam:detected/steam:expired (gated on tier/add-on).", + "unit": "asyncapi" + }, + { + "title": "ServerEnvelope marks `seq` required, but control frames omit it", + "severity": "MEDIUM", + "category": "envelope", + "page": "public/asyncapi.yaml", + "docs_claim": "ServerEnvelope is allOf-composed into EVERY server message with `required: [type, seq]` (lines 522-535) — i.e. spec promises `seq` on connected, subscribed, error, pong, snapshot:complete, etc.", + "reality": "Control frames are marshaled verbatim with no injected `seq`. `error` ({type,code,message}), `subscribed` ({type,channel,filters}), `pong` ({type}), `unsubscribed`, `filters_updated`, and the normal `snapshot:complete` carry NO `seq`. Only data/opportunity frames and `connected`/`heartbeat` stamp seq explicitly.", + "evidence": "sendError websocket.go:6119-6123 ({type,code,message} only); subscribed 4394-4398; pong 3801; sendControlJSON 5995-6012 marshals the map as-is (no seq added). snapshot:complete normal 5556-5559 has no seq.", + "current_status": "still-present", + "confidence": "high", + "fix": "Either drop `seq` from ServerEnvelope.required (make it optional, present only on sequenced data/opportunity/connected/heartbeat frames), or document that control frames are exempt from the envelope. Current `required:[type,seq]` is violated by most control messages.", + "unit": "asyncapi" + }, + { + "title": "Subscribed frame is per-channel {type,channel,filters}, not {channels,sports,sportsbooks...}", + "severity": "MEDIUM", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "Subscribed payload has `channels` (array|null) plus inline `sports`/`sportsbooks`/`leagues`/`markets`/`eventIds`/`min_ev`/`min_profit` fields (lines 264-277).", + "reality": "One `subscribed` frame is emitted PER channel, shaped `{type:'subscribed', channel:'', filters:{...}, filter_warnings?}`. There is a single scalar `channel` (not a `channels` array), filters are nested under a `filters` object (keyed sport/league/sportsbook/market/event — singular), and an optional `filter_warnings` field the spec omits.", + "evidence": "websocket.go:4393-4402 — per-channel loop emitting {type:subscribed, channel:ch, filters:c.filtersSnapshot()} (+filter_warnings); filtersSnapshot builds keys sport/league/sportsbook/market (6127-6144).", + "current_status": "still-present", + "confidence": "high", + "fix": "Rework Subscribed: scalar `channel` (string), nested `filters` object (sport/league/sportsbook/market/event arrays), optional `filter_warnings`. Note one frame is sent per subscribed channel.", + "unit": "asyncapi" + }, + { + "title": "Undocumented server->client message types (gamestate, closing_line, steam, odds:locked, source_health, control acks)", + "severity": "MEDIUM", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "The channel `messages` map (lines 108-128) and `receive` operation (146-163) enumerate only: connected, subscribed, opportunities_snapshot, initial, snapshot:complete, odds:update, odds:removed, ev/arb/middles/low_hold :detected/:expired, heartbeat, pong, error.", + "reality": "The server also emits: gamestate:update, gamestate:removed, gamestate:snapshot, closing_line:captured, steam:detected, steam:expired, odds:locked (SHA-3803 suspended-rows event), source_health (#897), token_refreshed (SHA), unsubscribed, filters_updated, filter_warning, resync_required (#971/SHA-3701). None are in the spec.", + "evidence": "odds:locked 849/1061; source_health 5901-5905 + isControlMessageType 5913-5915; token_refreshed 4330; unsubscribed 4436; filters_updated 4455; filter_warning 4517/5171; resync_required 3912; gamestate:* 1967/1987/5620; closing_line:captured 2057; steam:* via sendOpportunityDiff('steam') 1376.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add message schemas for odds:locked, source_health, token_refreshed, unsubscribed, filters_updated, filter_warning, resync_required, gamestate:update/removed/snapshot, closing_line:captured, steam:detected/expired; reference them in the channel messages map and the receive operation.", + "unit": "asyncapi" + }, + { + "title": "Heartbeat frame omits documented fields (carries connected/server/seq/global_seq/source_health)", + "severity": "LOW", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "Heartbeat payload is just envelope + `{type: const heartbeat}` (lines 485-490).", + "reality": "Heartbeat carries `{type, timestamp, connected:, server:'sharp-api-go', seq, global_seq, source_health?}`. The `connected`/`server`/`source_health` echo fields are undocumented.", + "evidence": "websocket.go:2235-2248.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add `connected` (integer), `server` (string), and optional `source_health` (object) to the Heartbeat payload schema. Note seq advances on each heartbeat so clients can detect a frozen stream.", + "unit": "asyncapi" + }, + { + "title": "subprotocol table claims JSON-only public protocol; msgpack v1.5 exists and is real", + "severity": "LOW", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "Subprotocols section says only `sharpapi-v1` (JSON throughout) is public, and 'additional subprotocols reserved for first-party SDKs ... may change without notice' (lines 13-26). defaultContentType application/json.", + "reality": "`sharpapi-v1.5` (JSON control + MessagePack data) is a fully implemented, stable subprotocol — data frames go out msgpack-encoded for v1.5 clients. Calling it merely 'reserved / may change' understates it; the whole hub pre-serializes both JSON and msgpack twins for fan-out.", + "evidence": "websocket.go:43 subprotocolV15='sharpapi-v1.5' (// JSON control + MessagePack data); preSerializedChunk holds jsonData+msgpackData (673-677); marshalMsgpack used throughout (832,1135). This is arguably acceptable as 'public clients should negotiate v1', so LOW.", + "current_status": "still-present", + "confidence": "medium", + "fix": "Optionally document sharpapi-v1.5 as the MessagePack data variant (control frames stay JSON) rather than implying it is unstable/private. Low priority if intentionally hiding it from public clients.", + "unit": "asyncapi" + }, + { + "title": "Error message references a non-existent 'Close codes' section", + "severity": "LOW", + "category": "conceptual", + "page": "public/asyncapi.yaml", + "docs_claim": "Error message summary: 'Auth and rate-limit errors are delivered via WebSocket close codes — see \"Close codes\" below.' (lines 506-509). Info description also defers close-code semantics to '/api-reference/websocket' (line 49).", + "reality": "There is no 'Close codes' section anywhere in asyncapi.yaml — the cross-reference dangles. The real close codes are 4001 (auth-failed/displaced), 4003 (entitlements changed), 1012 (server restarting), 1008 ClosePolicyViolation (sustained backpressure).", + "evidence": "Spec self-search: no 'Close codes' heading present. Code: websocket.go:156 (4001), 164 (4003), 567 (1012), 3434 (4001 displaced), 3467 (4003), 3889/3902/3950 (ClosePolicyViolation/1008).", + "current_status": "still-present", + "confidence": "high", + "fix": "Either add a 'Close codes' block to the spec description (4001 auth/displace, 4003 entitlements changed, 1012 restart, 1008 backpressure — no 4029) or remove the dangling 'see Close codes below' phrase and point to the actual API-reference page.", + "unit": "asyncapi" + }, + { + "title": "connected frame has additional undocumented fields (filter_hash, resume contract, resume_source, source_health)", + "severity": "LOW", + "category": "asyncapi-drift", + "page": "public/asyncapi.yaml", + "docs_claim": "Connected requires [stream_id, tier, features, channels, global_seq, books] plus optional type/message (lines 235-253).", + "reality": "connected also emits `seq` (int), `filter_hash` (resume scope fingerprint, always present), `resume` (streamResumeContract object, always present), and on resume attempts `resumed`/`replayed_count`/`resume_source`/`from_seq`/`fallback_reason`. features may also include `game_state` and `source_health` keys beyond the documented ev/arbitrage/middles/low_hold map.", + "evidence": "websocket.go:5844-5891 (filter_hash 5860, resume 5891, resume_source/from_seq 5875-5879, resumed/replayed_count 5869-5870); features['game_state'] 5807, features['source_health'] 5812.", + "current_status": "still-present", + "confidence": "high", + "fix": "Document the always-present `seq`, `filter_hash`, `resume` (contract object) fields and the resume-only fields (resumed, replayed_count, resume_source, from_seq, fallback_reason) on Connected; extend TierFeatures with optional `game_state` and `source_health` booleans.", + "unit": "asyncapi" + }, + { + "title": "Event-matching _bN/_g{N} doubleheader recipe + 'Live vs Pre-Match same event ID' sections are EN-only — never back-ported to de/es/pt-BR", + "severity": "HIGH", + "category": "i18n", + "page": "content/{de,es,pt-BR}/concepts/event-matching.mdx", + "docs_claim": "Localized event-matching pages (141 lines each) end after 'Two Types of Event IDs' / 'How Canonical IDs Are Generated' and have NO start-time bucket (_b{N}), NO doubleheader suffix (_g{N}), NO cross-bucket grouping recipe, and NO 'Live vs Pre-Match Share The Same Event ID' section.", + "reality": "The EN page (215 lines) carries four substantive sections the locales lack: '### Start-time bucket suffix (`_b{N}`)' (en L70), '### Doubleheader suffix (`_g{N}`)' (en L92), '### Grouping one matchup across IDs' (two-level collapse recipe, en L96-116) and '## Live vs Pre-Match Share The Same Event ID' (en L118). These describe how a client must strip _g{N}/_b{N} suffixes to reunite the same matchup across books — without it, localized-docs clients will mis-bucket doubleheaders and fragment cross-book joins.", + "evidence": "commit 9a9af0340 message states verbatim: '_g{N} ... English only — the de/es/pt-BR event-matching pages predate the SHA-3432 _bN section entirely and need a separate i18n backfill (flagged in the PR)'. git log for content/de/concepts/event-matching.mdx shows only 9d35b71 (initial i18n) — never touched by 46e7175 (_bN) or 9a9af03 (_g{N}). grep for '_g{N}|_bN|doubleheader|same event' returns matches in en only, 0 in de/es/pt-BR. Line counts: en=215, de=es=pt-BR=141.", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "de, es, pt-BR (all 3 non-en locales identically missing)", + "fix": "Back-port the four EN-only sections (Start-time bucket _b{N}, Doubleheader _g{N}, Grouping one matchup across IDs, Live vs Pre-Match Share The Same Event ID) from content/en/concepts/event-matching.mdx into all three locale files. This is the i18n backfill the 9a9af03 PR explicitly deferred.", + "unit": "i18n" + }, + { + "title": "'New (May 2026)' nested reference blocks + numerical_id/abbreviation/metadata fields documented in 10 EN pages but missing from all locale api-reference pages", + "severity": "HIGH", + "category": "i18n", + "page": "content/{de,es,pt-BR}/api-reference/{odds,teams,sportsbooks,leagues,markets,sports,opportunities-ev,opportunities-middles,opportunities-arbitrage}.mdx", + "docs_claim": "Localized api-reference pages document neither the nested reference blocks (home/away/sport_ref/league_ref/market_ref/sportsbook_ref) nor the numerical_id/abbreviation/logo/city/mascot/conference/division fields. grep 'numerical_id' across content/es returns it on only 1 page (entity-reference-ids.mdx); same for de and pt-BR. EN has it on 10 pages.", + "reality": "These are real served fields. endpoints.go:1082 declares `NumericalID int `json:\"numerical_id,omitempty\"``; the teams/sportsbooks responses (endpoints.go:947-1103) emit numerical_id + abbreviation. EN odds.mdx L347-352 documents the nested *_ref objects each containing {id,numerical_id,...}; EN teams.mdx L155-162 and sportsbooks.mdx L316 document the metadata fields. Locale users have no documentation that these response fields exist.", + "evidence": "grep -rln 'numerical_id': en=10 files, de=1, es=1, pt-BR=1 (only entity-reference-ids.mdx in each locale). EN section headers 'New (May 2026): nested reference blocks' (odds.mdx:379, opportunities-middles/ev/arbitrage), 'New (May 2026): numerical_id, abbreviation, and team metadata' (teams.mdx:169), 'New (May 2026): numerical_id' (sportsbooks.mdx:332) absent in es/pt-BR. api-reference line counts diverge on 14/35 pages with EN consistently longer (odds en=569 vs locales=505; teams en=225 vs 194; sportsbooks en=485 vs 469; etc). Live API confirms invalid_api_key envelope shape but agent key was rejected for /teams; Go source is authoritative.", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "de, es, pt-BR (all 3); 9+ api-reference pages each", + "fix": "Back-port the 'New (May 2026)' nested-reference-block and numerical_id/abbreviation/metadata field documentation from the EN odds/teams/sportsbooks/leagues/markets/sports/opportunities-* pages into all three locales. These are additive served fields that localized users currently cannot discover.", + "unit": "i18n" + }, + { + "title": "Fabricated WebSocket close code 4029 ('stream limit exceeded') replicated identically into all 3 locales", + "severity": "HIGH", + "category": "errcode", + "page": "content/{de,es,pt-BR}/api-reference/websocket.mdx (L597, L889, L899) + content/{de,es,pt-BR}/streaming/websocket.mdx", + "docs_claim": "All locale websocket pages state '`4029` | Stream-Limit überschritten / Límite de streams superado / Limite de streams excedido' and '4029 bedeutet/significa zu viele / demasiadas / muitas conexões'.", + "reality": "Code 4029 does not exist in the server. websocket.go defines only wsCloseAuthFailed=4001 (L156) and wsCloseEntitlementsChanged=4003 (L164). Over-limit is handled by newer-wins displacement of the OLDER socket with a 4001 close frame ('displaced by newer session', websocket.go:3434), not a 4029 rejection of the new one. SSE over-limit returns HTTP 429 too_many_streams. grep '4029' across the entire Go tree returns zero hits.", + "evidence": "websocket.go:156 wsCloseAuthFailed=4001; :164 wsCloseEntitlementsChanged=4003; :3434 FormatCloseMessage(4001, 'displaced by newer session'); grep -rn '4029' sago-main/*.go = (none). errcodes.go:32 TooManyStreams='too_many_streams' (the 429 path). This is an EN-origin error mirrored verbatim ×3.", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "de, es, pt-BR (mirrors the EN bug; ×3 replication)", + "fix": "In all locales (and EN), replace 4029 with the real behavior: WS over-limit displaces the OLDER connection with close code 4001 ('displaced by newer session'); there is no 4029. Keep 4001 (auth-fail/displaced) and 4003 (entitlements changed) only.", + "unit": "i18n" + }, + { + "title": "EV devig_method documented lowercase 'power'; server emits uppercase 'POWER' — replicated ×4", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/{de,es,pt-BR}/api-reference/opportunities-ev.mdx (L130)", + "docs_claim": "All locale EV example payloads show `\"devig_method\": \"power\"` (lowercase), identical to EN L130.", + "reality": "The EV engine sets devig_method to the constant DevigMethod = \"POWER\" (uppercase). pkg/evengine/types.go:81 `DevigMethod = \"POWER\"`; compute.go:2371 `DevigMethod: DevigMethod`; serialized as-is into the `devig_method` JSON field (types.go:839). A client doing a case-sensitive equality check on the documented 'power' would fail to match the real 'POWER'.", + "evidence": "pkg/evengine/types.go:81 DevigMethod = \"POWER\"; compute.go:2371 assigns it; types.go:839 emits it. All four locale EV pages (en/de/es/pt-BR opportunities-ev.mdx L130) show lowercase 'power'. Live EV read returned no items (off-peak) but the constant is authoritative.", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "de, es, pt-BR (mirrors EN; ×3 replication of an EN bug)", + "fix": "Change the example value to `\"devig_method\": \"POWER\"` in all four locales' opportunities-ev.mdx to match the server constant (pkg/evengine/types.go:81).", + "unit": "i18n" + }, + { + "title": "Arbitrage leg probability field documented as implied_probability; served field is odds_probability — replicated ×4", + "severity": "MEDIUM", + "category": "field-name", + "page": "content/{de,es,pt-BR}/api-reference/opportunities-arbitrage.mdx (L128, L140 examples; L259 field table)", + "docs_claim": "All locale arbitrage pages document each leg's probability field as `implied_probability` (example values 0.408/0.574 at L128/L140, field-table row L259), mirroring EN.", + "reality": "The served REST output renames the field to `odds_probability` on each leg. transforms.go:1069/1306/1532/1547/1666 all emit \"odds_probability\"; main.go:624 declares `OddsProbability float64 `json:\"odds_probability\"``. (The arbengine internal struct uses implied_probability — pkg/arbengine/types.go:111 — but that is transformed before serving.) Docs expose the internal name, not the served one.", + "evidence": "transforms.go:1069 \"odds_probability\": implProb; main.go:624 OddsProbability json:\"odds_probability\"; pkg/arbengine/types.go:111 ImpliedProb json:\"implied_probability\" (internal, pre-transform). grep arb pages: 'odds_probability'=0, 'implied_probability'=3 in every locale incl. EN. Live arb read returned no items off-peak; code is authoritative.", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "de, es, pt-BR (mirrors EN; ×3 replication)", + "fix": "Rename the leg probability field from implied_probability to odds_probability in the example JSON (L128/L140) and the Response Fields / Leg Object table (L259) across all four locales, matching the served output (transforms.go / main.go:624).", + "unit": "i18n" + }, + { + "title": "Streaming overview: 'Track rows by id, not a composite key' warning callout is EN-only — missing from all 3 locales", + "severity": "MEDIUM", + "category": "i18n", + "page": "content/{de,es,pt-BR}/streaming/overview.mdx", + "docs_claim": "Localized streaming/overview pages (232 lines each) lack any guidance to track rows by the stable `id`; they have no equivalent of the EN warning callout.", + "reality": "EN streaming/overview.mdx (250 lines) has a 'Track rows by `id`, not by a composite key' (en L145-160, added in commit 95660f1 / PR #229) explaining that for asian-handicap/team-total/over-under markets the `line` updates in place under a stable `id`, so a composite key `(event_id, market_type, selection, line)` silently misses every line-change. Localized clients following the older translated example will hit this exact bug.", + "evidence": "grep 'composite key|stable identifier' across streaming/: en=1 file, de/es/pt-BR=0. Line counts streaming/overview: en=250 vs locales=232 (the 18-line gap is this callout + expanded Node.js example). git log content/de/streaming/overview.mdx shows it never received commit 95660f1.", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "de, es, pt-BR", + "fix": "Back-port the 'Track rows by `id`, not by a composite key' warning callout (en streaming/overview.mdx L145-160) into all three locales.", + "unit": "i18n" + }, + { + "title": "SSE channel CSV semantics fix landed unevenly across locales (es/pt-BR/de got stream.mdx, but odds-comparison/odds-batch nested-ref reached only de and pt-BR, not es)", + "severity": "LOW", + "category": "i18n", + "page": "content/es/api-reference/{odds-comparison,odds-batch}.mdx", + "docs_claim": "ES odds-comparison.mdx and odds-batch.mdx contain zero 'nested reference' / numerical_id documentation, while de and pt-BR versions of the same two pages do (de: odds-comparison=1, odds-batch=2; pt-BR: odds-comparison=1, odds-batch=3).", + "reality": "The May-2026 nested-reference-block additions reached de and pt-BR for odds-comparison/odds-batch but not es — an asymmetric, partial i18n propagation. The core 9a9af03 stream.mdx CSV fix and Bet365-Hobby tier fix did reach all 4 locales correctly (verified), but the broader nested-ref backfill is inconsistent. ES is the most under-propagated locale.", + "evidence": "grep nested-reference: de odds-comparison=1/odds-batch=2; es=0/0; pt-BR=1/3. Confirms the May-2026 entity-ref rollout was applied per-locale unevenly, leaving es the most stale. (By contrast Bet365 tier=Hobby, SSE channels-CSV note, and is_main_line note are present in all 4 — those fixes replicated correctly.)", + "current_status": "still-present", + "confidence": "high", + "locale_scope": "es (gap vs de/pt-BR on odds-comparison + odds-batch)", + "fix": "Bring es odds-comparison.mdx and odds-batch.mdx up to parity with the de/pt-BR nested-reference-block additions; audit es as the most-stale locale during the broader May-2026 backfill.", + "unit": "i18n" + }, + { + "title": "pinnacle-odds-changed-at concept page: EN 'Benchmarking Across Vendors' / wire_received_at content not back-ported to locales (es most divergent)", + "severity": "LOW", + "category": "i18n", + "page": "content/{de,es,pt-BR}/concepts/pinnacle-odds-changed-at.mdx", + "docs_claim": "Localized pages have fewer sections than EN: en=78 lines (sections incl. 'Benchmarking Across Vendors'), de=71, pt-BR=71, es=60 (es additionally missing the freshness-detail and benchmarking material).", + "reality": "EN gained content via commit 1987219 ('document wire_received_at as the field to benchmark pipeline latency') which never reached the locales (git log for es shows only 9d35b71 i18n + the c63f3d7 trailing-slash + 80e1c04 revert). The 80e1c04 single-timestamp revert DID reach all locales, so the field-name guidance is consistent, but the benchmarking depth differs. Lower severity because no factual field-name contradiction — purely depth/completeness.", + "evidence": "Line counts en=78/de=71/es=60/pt=71. EN headings include '## Benchmarking Across Vendors' (en L61) absent from locales. git log content/es/concepts/pinnacle-odds-changed-at.mdx: 80e1c04, c63f3d7, 9d35b71 (missing 1987219).", + "current_status": "still-present", + "confidence": "medium", + "locale_scope": "de, es, pt-BR (es most divergent at 60 lines)", + "fix": "Optionally back-port the EN 'Benchmarking Across Vendors' / wire_received_at section (en L61-68) to the locales; lowest priority since no field-name contradiction exists.", + "unit": "i18n" + }, + { + "title": "in_play inner fields documented as camelCase (currentPitcher/currentBatter) but API emits snake_case (pitcher/batter)", + "severity": "CRITICAL", + "category": "field-name", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "The Baseball in_play block section states inner field names are camelCase: `currentPitcher`, `currentBatter`, plus an explicit callout 'Inner field names are camelCase — they are not snake-cased by the API transform.' Example JSON shows \"currentPitcher\": \"Zach Agnos\", \"currentBatter\": \"Tyrone Taylor\". The Sport-specific table also cross-references in_play.currentPitcher (line 106) and a currentPitcher/currentBatter row table (lines 137-138).", + "reality": "buildGamestateInPlay normalizes the raw camelCase adapter block to snake_case: it reads raw[\"currentBatter\"]→emits ip[\"batter\"], raw[\"currentPitcher\"]→emits ip[\"pitcher\"] (gamestate_inplay.go:151-156). annotateGamestateInPlay replaces the raw passthrough entirely so the wire NEVER carries currentBatter/currentPitcher. The unit test asserts exactly this (gamestate_inplay_test.go:67-68, 258-263). Live confirms: in_play blocks contain {balls, strikes, outs, runners, period, period_number, batter, pitcher, batter_id, pitcher_id, last_play} — all snake_case; zero camelCase keys observed across 22 live baseball events (9 with at-bat micro-state, e.g. {\"batter\":\"Brandon Nimmo\",\"pitcher\":\"Louis Varland\"}).", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/gamestate_inplay.go:151-156 (raw[\"currentBatter\"]→ip[\"batter\"], raw[\"currentPitcher\"]→ip[\"pitcher\"]); gamestate_inplay_test.go:257-263 ('Renamed to OpticOdds parity; raw camelCase key is gone'); live: GET /api/v1/gamestate/baseball -> in_play:{\"batter\":\"Brandon Nimmo\",\"pitcher\":\"Louis Varland\"} (no currentBatter/currentPitcher anywhere)", + "current_status": "still-present", + "confidence": "high", + "fix": "Rename in_play fields to snake_case throughout: currentPitcher→pitcher, currentBatter→batter in the example JSON (lines 121-129), the field table (lines 137-138), and the cross-reference at line 106. DELETE the false callout on lines 117-118 ('Inner field names are camelCase — they are not snake-cased by the API transform.') and replace with: inner fields ARE snake_case. Add the omitted in_play fields: period, period_number, clock, time_min, time_sec, is_clock_stopped, last_play, batter_id, pitcher_id.", + "locale_scope": "en; replicates x4 in de/es/pt-BR mirrors", + "unit": "gamestate" + }, + { + "title": "in_play documented as camelCase-only baseball block; omits period/period_number/clock/time_min/time_sec/is_clock_stopped/last_play emitted for all live sports", + "severity": "HIGH", + "category": "field-missing", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "in_play is described only as a baseball-only at-bat block (Sport-specific table line 107: 'in_play | baseball | object — at-bat state'). The block's documented fields are limited to outs/balls/strikes/runners/currentPitcher/currentBatter.", + "reality": "buildGamestateInPlay emits a sport-aware in_play block for EVERY live sport, carrying common temporal fields: period (mirror of game_period), period_number, clock, time_min, time_sec, is_clock_stopped (clock-driven sports), and last_play — plus the baseball at-bat fields only when present (gamestate_inplay.go:108-165). Live non-baseball-microstate events still show in_play:{\"period\":\"B5\",\"period_number\":5}. batter_id/pitcher_id are also emitted and populated in prod (e.g. baseball_mlb_brandon_nimmo).", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/gamestate_inplay.go:108-135 (period/period_number/clock/time_min/time_sec/is_clock_stopped/last_play) and :159-164 (batter_id/pitcher_id); live: in_play union of keys = balls,batter,batter_id,last_play,outs,period,period_number,pitcher,pitcher_id,runners,strikes", + "current_status": "still-present", + "confidence": "high", + "fix": "Reframe in_play as a sport-aware block (not baseball-only). Document the common temporal subfields (period, period_number, clock, time_min, time_sec, is_clock_stopped, last_play) present on live events across sports, plus the baseball at-bat subfields (balls, strikes, outs, runners, batter, pitcher, batter_id, pitcher_id) present-only when a covering book exposes them.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "Atlas player IDs documented (in code comments) as off in prod, but batter_id/pitcher_id are live and undocumented", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "The docs do not mention batter_id or pitcher_id at all. (Code comments claim atlas IDs are gated behind api-adapters INPLAY_ATLAS_PLAYER_IDS, 'off in prod'.)", + "reality": "Live prod gamestate/baseball returns in_play.batter_id / in_play.pitcher_id populated (e.g. \"batter_id\":\"baseball_mlb_brandon_nimmo\", \"pitcher_id\":\"baseball_mlb_louis_varland\") on multiple events — the adapter atlas-id flag is effectively ON in prod for at least some books. The Go side surfaces them present-gated (gamestate_inplay.go:159-164).", + "evidence": "live: GET /api/v1/gamestate/baseball -> in_play:{\"batter_id\":\"baseball_mlb_brandon_nimmo\",\"pitcher_id\":\"baseball_mlb_louis_varland\"}; code path /tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/gamestate_inplay.go:159-164", + "current_status": "still-present", + "confidence": "high", + "fix": "Document in_play.batter_id and in_play.pitcher_id as present-only Atlas player IDs (string, e.g. baseball_mlb_), noting coverage varies by contributing book.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "Top-level period_number, scores (per-period), and stats (box-score) blocks emitted live but entirely undocumented", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "Event State Fields tables list temporal (game_period/game_clock), situational, and sport-specific fields. They do NOT mention top-level period_number, a scores object (per-period totals), or a stats box-score object.", + "reality": "The handler annotates every event with period_number (gamestate_period_number.go:143), a scores block (gamestate_scores.go:112 sets state[\"scores\"]), and a per-team stats block (annotateGamestateStats). Live shows all three: top-level \"period_number\":2, \"scores\":{\"away\":{\"aggregate\":null,\"periods\":{\"period_1\":0},\"total\":0},\"home\":{...}}, and \"stats\":{\"away\":[{\"period\":\"all\",\"stats\":{\"hits\":0}}],\"home\":[...]}, plus away_period_scores/home_period_scores arrays.", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/endpoints.go:1413-1423 (annotateGamestatePeriodNumber/Scores/Stats calls); gamestate_scores.go:112; live: top-level keys include period_number, scores, stats, away_period_scores, home_period_scores", + "current_status": "still-present", + "confidence": "high", + "fix": "Add documentation for top-level period_number (integer companion to game_period), the scores block ({home,away}:{total,aggregate,periods:{period_N:int}}), away_period_scores/home_period_scores arrays, and the stats box-score block ({home,away}:[{period,stats:{...}}]). Mark all as additive/present-only.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "Baseball box-score stat fields (doubles_*, home_runs_*, rbi_*, walks_*, strikeouts_*, stolen_bases_*, triples_*, left_on_base_*) emitted live but undocumented", + "severity": "MEDIUM", + "category": "field-missing", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "The Sport-specific table lists baseball fields as only: hits_home/away, errors_home/away, home_pitcher/away_pitcher, in_play.", + "reality": "The aggregator (mlb_statsapi source, Phase 3c) emits a much richer baseball stat set surfaced as flat top-level fields. Live gamestate/baseball top-level key union includes doubles_home/away, home_runs_home/away, rbi_home/away, walks_home/away, strikeouts_home/away, stolen_bases_home/away, triples_home/away, left_on_base_home/away — none documented.", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/gamestate_stats.go:45-50 (Phase 3c MLB box-score depth); live top-level union: doubles_away/home, home_runs_away/home, rbi_away/home, walks_away/home, strikeouts_away/home, stolen_bases_away/home, triples_away/home, left_on_base_away/home", + "current_status": "still-present", + "confidence": "high", + "fix": "Extend the baseball sport-specific field table with the additional present-only stat fields (doubles, home_runs, rbi, walks, strikeouts, stolen_bases, triples, left_on_base — each *_home/*_away), noting they are emitted only when the mlb_statsapi source covers the event.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "consensus_at, join_key, last_play, last_play_updated_at top-level fields emitted live but undocumented", + "severity": "LOW", + "category": "field-missing", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "last_play appears only under the Situational table; consensus_at, join_key, and last_play_updated_at are not documented anywhere. The Always-present table also omits these.", + "reality": "Live gamestate/baseball events carry top-level consensus_at (float epoch), join_key (string, often empty), last_play (string), and last_play_updated_at (float epoch). last_play also appears at top level (docs only list it as Situational, which is fine) and inside in_play.", + "evidence": "live: GET /api/v1/gamestate/baseball -> top-level keys include \"consensus_at\":1782525216.494262, \"join_key\":\"\", \"last_play\":\"Bottom of the 1st\", \"last_play_updated_at\":1782525214.7157805", + "current_status": "still-present", + "confidence": "high", + "fix": "Document consensus_at (epoch seconds float, merge timestamp), last_play_updated_at (epoch seconds float), and join_key (cross-feed join key, may be empty). Decide whether join_key is intended for public exposure; if internal-only, note it as such.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "home_pitcher/away_pitcher described as 'starting' pitcher, cross-referencing nonexistent in_play.currentPitcher", + "severity": "LOW", + "category": "field-name", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "home_pitcher/away_pitcher row (line 106): 'starting pitcher display name (roster-level, not the pitcher currently on the mound — see in_play.currentPitcher below)'. Line 137 again calls the live pitcher in_play.currentPitcher.", + "reality": "The on-the-mound pitcher field is in_play.pitcher (snake_case), not in_play.currentPitcher. Top-level home_pitcher/away_pitcher are live (e.g. \"home_pitcher\":\"P Corbin\") on ~6 of 22 events, so the field itself exists, but the cross-reference target name is wrong.", + "evidence": "live: top-level \"home_pitcher\":\"P Corbin\",\"away_pitcher\":\"N Eovaldi\"; in_play live key is \"pitcher\" not \"currentPitcher\" (gamestate_inplay.go:154-156)", + "current_status": "still-present", + "confidence": "high", + "fix": "In the home_pitcher/away_pitcher description and line 137, change the cross-reference from in_play.currentPitcher to in_play.pitcher.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "Path-parameter sport list mismatch: documents 'snooker' which has no period grammar; omits supported sports (mma, volleyball, rugby) and the working ?sport= query param", + "severity": "LOW", + "category": "param-noop", + "page": "content/en/api-reference/gamestate.mdx", + "docs_claim": "Path Parameters table lists the sport set as baseball, basketball, football, hockey, soccer, tennis, table_tennis, cricket, esports, snooker, other. No mention of a ?sport= query parameter.", + "reality": "The handler also honors ?sport= and CSV ?sport=a,b as filters (endpoints.go:1332-1352, issue #252), which the docs never mention. The returned sports are whatever live shards exist (knownGamestateSports), not a fixed enum — code references mma, volleyball, rugby as live-but-ungrammar'd sports; 'snooker' is not referenced anywhere in the gamestate code. Unknown/absent sports correctly return empty data.", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/endpoints.go:1338-1352 (?sport= fallback + CSV); gamestate_period_number.go:28-29 + gamestate_inplay.go:170 list mma/esports/cricket/volleyball/table_tennis as real ungrammar'd sports; no 'snooker' token in any gamestate_*.go", + "current_status": "still-present", + "confidence": "medium", + "fix": "Document the ?sport= query parameter (single and CSV form) as an equivalent to the path form. Soften the sport list to 'examples include ...' rather than an exhaustive enum, and drop the unverifiable 'snooker' or confirm it ships; the live set is dynamic.", + "locale_scope": "en; replicates x4", + "unit": "gamestate" + }, + { + "title": "GET /api/v1/injuries (Enterprise) is undocumented and absent from OpenAPI spec", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "openapi.json + content/en/api-reference/_meta.js + enterprise.mdx", + "docs_claim": "No mention of an injuries endpoint anywhere. openapi.json has 35 paths, none for injuries (grep injuries = 0). enterprise.mdx documents only /futures and /futures/odds. _meta.js Enterprise section links only enterprise.mdx. The only 'injuries' string in en/ content is incidental prose in concepts/live-vs-prematch.mdx:75.", + "reality": "routes.go:81 registers apiKeyRoute GET /api/v1/injuries with TierFeature injuries, served by injuriesHandler. tiers.go:68 lists injuries only under the enterprise tier, so it is Enterprise-only (tierGateMiddleware('injuries') 403s every lower tier). New route added since the run-1 stale checkout.", + "evidence": "routes.go:81 (/api/v1/injuries, feature injuries, injuriesHandler); tiers.go:68 enterprise Features includes injuries; live: GET http://localhost:3003/api/v1/injuries with enterprise key -> 200. grep injuries openapi.json = 0.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add a /injuries path to openapi.json (Enterprise, 403 tier_restricted for lower tiers) and a doc page (e.g. api-reference/injuries.mdx) plus entries under the Enterprise section of enterprise.mdx and _meta.js. Replicate across de/es/pt-BR.", + "locale_scope": "en authoritative; mirrors to de/es/pt-BR (x4)", + "unit": "census-v2" + }, + { + "title": "GET /api/v1/odds/export is undocumented and absent from OpenAPI spec", + "severity": "HIGH", + "category": "missing-endpoint", + "page": "openapi.json + content/en/api-reference (no page)", + "docs_claim": "No /odds/export path in openapi.json (grep odds/export = 0) and no doc page or _meta.js entry. The Odds section of _meta.js lists odds, odds-delta, odds-best, odds-comparison, odds-batch, odds-closing only.", + "reality": "routes.go:71 registers apiKeyRoute GET /api/v1/odds/export with featureNone (all tiers), served by oddsExportHandler — a bulk streaming export (one consistent snapshot per request, cacheTTL=0 per the inline comment). New route since the run-1 stale checkout.", + "evidence": "routes.go:71 (/api/v1/odds/export, featureNone, oddsExportHandler); live: GET http://localhost:3003/api/v1/odds/export?sport=baseball&format=json with key -> 200. grep odds/export openapi.json = 0.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /odds/export to openapi.json (all-tier; note streaming/non-paginated bulk semantics and format param) and a doc page under the Odds section + _meta.js. Replicate x4.", + "locale_scope": "en authoritative; mirrors x4", + "unit": "census-v2" + }, + { + "title": "GET /api/v1/odds/historical (history/Enterprise) absent from OpenAPI and has no doc page", + "severity": "MEDIUM", + "category": "missing-endpoint", + "page": "openapi.json + content/en/api-reference (no page)", + "docs_claim": "No /odds/historical path in openapi.json (grep = 0) and no api-reference page. _meta.js Historical section lists only historical-clv and historical-odds-closing.", + "reality": "routes.go:88 registers apiKeyRoute GET /api/v1/odds/historical with TierFeature history (Enterprise per tiers.go:68), backed by historical.OddsHistoricalHandlerBacked. Distinct from /historical/odds/closing.", + "evidence": "routes.go:88; tiers.go:68 history is enterprise-only; live: GET /api/v1/odds/historical?event_id=x -> 400 (route reachable, param validation), proving the route exists.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /odds/historical to openapi.json and a doc page (Enterprise/Historical section). Replicate x4.", + "locale_scope": "en authoritative; mirrors x4", + "unit": "census-v2" + }, + { + "title": "GET /api/v1/odds/closing (Closing Line) is documented but missing from OpenAPI spec", + "severity": "MEDIUM", + "category": "openapi-drift", + "page": "openapi.json (missing) vs api-reference/odds-closing.mdx + _meta.js", + "docs_claim": "_meta.js Odds section has odds-closing 'Closing Line' and the page api-reference/odds-closing.mdx exists, documenting GET /api/v1/odds/closing. openapi.json does NOT contain /odds/closing (grep odds/closing = 0).", + "reality": "routes.go:87 registers apiKeyRoute GET /api/v1/odds/closing with TierFeature closing_line (Pro+ per tiers.go:54/68), served by closingHandler. The route is real and documented in MDX but the OpenAPI spec omits it.", + "evidence": "routes.go:87 (/api/v1/odds/closing, closing_line, closingHandler); api-reference/odds-closing.mdx present; grep odds/closing openapi.json = 0.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /odds/closing to openapi.json (Pro+ / closing_line gate) so the spec matches the existing MDX page.", + "locale_scope": "spec is locale-agnostic (single openapi.json)", + "unit": "census-v2" + }, + { + "title": "Historical endpoints under /api/v1/historical/* are largely missing from OpenAPI; several lack doc pages", + "severity": "MEDIUM", + "category": "openapi-drift", + "page": "openapi.json + content/en/api-reference", + "docs_claim": "openapi.json contains NO /historical/* paths at all (grep /historical = 0). _meta.js documents only historical-clv and historical-odds-closing; there is no page for movements, opportunities, opportunities/:id, or summary.", + "reality": "routes.go:89-94 register six history-gated (Enterprise) routes: /historical/odds/movements, /historical/odds/closing, /historical/opportunities, /historical/opportunities/:id, /historical/summary, /historical/clv. Only closing and clv have MDX pages; none are in openapi.json.", + "evidence": "routes.go:89 (movements), :90 (closing), :91 (opportunities/:id), :92 (opportunities), :93 (summary), :94 (clv); tiers.go:68 history is enterprise-only; grep /historical and /clv in openapi.json = 0 matches.", + "current_status": "still-present", + "confidence": "high", + "fix": "Add /historical/odds/movements, /historical/odds/closing, /historical/opportunities, /historical/opportunities/{id}, /historical/summary, /historical/clv to openapi.json; add doc pages for movements, opportunities (+detail), and summary. Replicate doc pages x4.", + "locale_scope": "spec locale-agnostic; new MDX pages mirror x4", + "unit": "census-v2" + }, + { + "title": "SSE stream path aliases (/stream/odds, /stream/opportunities, /stream/all, /stream/gamestate, /stream/events) are absent from OpenAPI", + "severity": "LOW", + "category": "openapi-drift", + "page": "openapi.json + api-reference/stream.mdx", + "docs_claim": "openapi.json contains only /stream (one path). The SSE doc page exists (stream.mdx) but the spec lists no per-channel aliases.", + "reality": "routes.go:113-118 register six SSE endpoints: /stream, /stream/odds, /stream/opportunities, /stream/all, /stream/gamestate (gated featureGameState), /stream/events/. Only /stream is in the spec. /stream/gamestate has a different gate (gamestate add-on) than the others (streaming add-on).", + "evidence": "routes.go:113-118; openapi.json normalized paths include only /api/v1/stream.", + "current_status": "still-present", + "confidence": "high", + "fix": "Document the SSE path aliases (at least mention them in stream.mdx) and add the aliases to openapi.json, noting /stream/gamestate requires the gamestate add-on while others require the streaming add-on.", + "locale_scope": "spec locale-agnostic; MDX note mirrors x4", + "unit": "census-v2" + }, + { + "title": "ANY /mcp customer-facing route is undocumented and absent from OpenAPI", + "severity": "LOW", + "category": "missing-endpoint", + "page": "openapi.json + docs nav", + "docs_claim": "No /mcp path in openapi.json and no doc page or nav entry.", + "reality": "routes.go:151 registers apiKeyRoute ANY /mcp with featureNone (all authenticated tiers), served by mcpHandler (Model Context Protocol endpoint). Customer-facing (api-key auth), undocumented.", + "evidence": "routes.go:151 (apiKeyRoute ANY /mcp, featureNone, mcpHandler).", + "current_status": "still-present", + "confidence": "medium", + "fix": "Decide whether /mcp is a public surface; if so, add to openapi.json and a docs page. If intentionally undocumented, note as internal/beta. Low severity because it is non-standard (MCP transport, not a REST resource).", + "locale_scope": "spec locale-agnostic", + "unit": "census-v2" + }, + { + "title": "'steam' feature (Pro+) is a stream/WS-only opportunity channel and is undocumented as such", + "severity": "MEDIUM", + "category": "tier", + "page": "tiers.go vs docs nav (stream.mdx / websocket.mdx)", + "docs_claim": "No mention of a 'steam' opportunity type/channel in api-reference content (the only steam hit is incidental prose in historical-clv.mdx:252). _meta.js Opportunities section lists ev, arbitrage, middles, middles-summary, low-hold only.", + "reality": "tiers.go:43/54/68 include steam as a Pro/Sharp/Enterprise feature. There is NO REST /opportunities/steam route (grep steam routes.go = 0); steam is emitted only via SSE/WS (streaming.go:2219 sendOpportunityDiff(..., 'steam', ...); websocket_channels_test.go:89/116 list steam among channels). So steam is a streaming-only opportunity channel the docs never describe.", + "evidence": "tiers.go:43,54,68 Features include steam; streaming.go:2219 emits steam diff; websocket_channels_test.go:89,116 enumerate steam channel; no steam pattern in routes.go (only stream path aliases).", + "current_status": "new", + "confidence": "high", + "fix": "Document steam as a Pro+ streaming opportunity channel in stream.mdx and websocket.mdx (and the WS channels list / asyncapi.yaml), and reflect it in pricing/tier feature tables. Do NOT add a REST /opportunities/steam page — it has no REST route.", + "locale_scope": "en authoritative; mirrors x4 + asyncapi", + "unit": "census-v2" + }, + { + "title": "No phantom endpoints in openapi.json — all 35 spec paths map to real routes (verification confirmation)", + "severity": "LOW", + "category": "other", + "page": "openapi.json vs routes.go", + "docs_claim": "openapi.json lists /events/{eventId}/markets, /events/{eventId}/odds, /account/keys/{keyId}/rotate, /opportunities/middles/summary — none of which appear as literal Pattern strings in routes.go.", + "reality": "These are NOT phantom: they are served by catch-all subrouters. events/{id}/markets and events/{id}/odds are dispatched inside eventDetailHandler (main.go: TrimPrefix /api/v1/events/, HasSuffix /odds and /markets); account/keys/{id}/rotate is dispatched by keysAccountRouter -> rotateKeyHandler (keys.go:609/612 POST /api/v1/account/keys/:keyId/rotate); middles/summary is handled by middleByIDOrSummaryHandler (routes.go:103, :idOrSummary). All 35 spec paths resolve to live handlers.", + "evidence": "main.go eventDetailHandler routes /odds and /markets suffixes; keys.go:609-612 rotateKeyHandler; routes.go:103 middleByIDOrSummaryHandler.", + "current_status": "not-a-bug", + "confidence": "high", + "fix": "No change needed — recorded so the parent agent knows the spec has no phantom paths; the drift is one-directional (spec is MISSING real routes, not inventing fake ones).", + "locale_scope": "n/a", + "unit": "census-v2" + }, + { + "title": "openapi-version.json sidecar version (2.1.0) disagrees with openapi.json info.version (3.1.0); x-generated-at is stale", + "severity": "MEDIUM", + "category": "openapi-drift", + "page": "public/openapi-version.json vs public/openapi.json", + "docs_claim": "openapi-version.json declares version 2.1.0, x-generated-at 2026-05-20T23:30:42-04:00, x-commit-sha 13f97e3.", + "reality": "openapi.json declares info.version 3.1.0 (and openapi 3.1.0). The sidecar version (2.1.0) does not match info.version (3.1.0). info.version also appears erroneously set equal to the OpenAPI dialect version (3.1.0) rather than an independent doc version. The sidecar x-generated-at (2026-05-20) predates the routes added since the run-1 stale checkout (injuries/export/historical), confirming the spec is stale relative to origin/main routes.go.", + "evidence": "openapi-version.json:2 version=2.1.0, :3 x-generated-at=2026-05-20; openapi.json info.version=3.1.0 and openapi=3.1.0 (json read).", + "current_status": "still-present", + "confidence": "high", + "fix": "Reconcile the two version stamps: set openapi-version.json.version to match openapi.json info.version (and make info.version an independent doc version, not the 3.1.0 dialect). Regenerate openapi.json (refresh x-generated-at/x-commit-sha) from origin/main so it includes injuries, odds/export, odds/closing, odds/historical, and the /historical/* family.", + "locale_scope": "spec/sidecar locale-agnostic", + "unit": "census-v2" + } + ], + "gap_notes": [ + { + "unit": "concepts", + "live_checked": true, + "notes": "Verified CORRECT (no finding): (1) EV formula `EV% = (fair_probability × decimal_odds - 1) × 100` matches reality and live EV opp fields (ev_percentage, fair_probability, event_id, market_type, event_name, confidence, devig_method=\\\"POWER\\\" uppercase). (2) Arbitrage page math is internally consistent (sum 1/decimal < 1; +145=2.45, -135=1.74; 3-way example sums correctly) and endpoint/min_profit references are valid. (3) odds-formats American/decimal conversion formulas and implied-probability=1/decimal are correct; only the opening nested-JSON example is wrong (separate finding). (4) liquidity.mdx field names ALL verified against Go: max_bet, volume, volume_24h, open_interest, exchange_token_id (main.go:653-657, compact_delta.go:49-52); Pinnacle live row shows max_bet:50 confirming the emitting-books claim. (5) live-vs-prematch tier table correct for Pinnacle/Bookmaker/Circa=Sharp, Bet365/BetMGM/Caesars=Hobby, DraftKings/FanDuel=Free (sportsbooks.go:21-67) — the previously-fixed Bet365 live=hobby is correct now; only Novig/Polymarket=Free is wrong (finding). 403 tier_restricted shape verified (tiers.go:464-482). Free=12/60s rate-limit correct. `live=true` is a valid alias of `is_live` (parse.go:146). (6) pinnacle-odds-changed-at: `timestamp` is the sole per-odd freshness field (live row has only `timestamp`, no odds_changed_at/last_seen_at/wire_received_at), and /health cdn_fetch_pct_1h/_24h exist (pinnacle_cadence.go:28-29) — page fully accurate. (7) polymarket-resolution enum/field correct (main.go:584-587/705; polymarket.py:863-910); only the example field names are off. (8) entity-reference-ids numerical_id examples verified live: pinnacle=28, moneyline=878 (exact match); home/away nested blocks present on mapped (MLB) rows and correctly absent on unmapped (cricket) rows per the page's own caveat. Logo HOST is the only entity-ref error. live_checked via OVH :3003 (SHARPAPI_KEY from /root/sharpapi.io/.env): /api/v1/odds (pinnacle, cricket + baseball) and /api/v1/opportunities/ev." + }, + { + "unit": "sdks", + "live_checked": true, + "notes": "VERIFIED CORRECT (no finding): (1) MCP tool surface — all 17 tool names in mcp.go:123-203 exactly match the mcp.mdx table (4 reference, get_events + 5 odds/events, 2 futures, 4 opportunities, get_account_info). (2) MCP protocol version 2025-03-26 matches mcp.go:361. (3) MCP auth methods (X-API-Key / Bearer / api_key query) match mcp.go:20 + routes.go:151 apiKeyRoute. (4) MCP rate-limit table (12/120/300/1000/10000) matches tiers.go tierRateLimits. (5) Package names correct: PyPI 'sharpapi' (pyproject.toml:6 v0.4.1), npm '@sharp-api/client' (package.json:2 v0.4.1). (6) Python resource method names (odds.get/best/comparison/batch, ev.get, arbitrage.get, middles.get, low_hold.get, sports.list, leagues.list, sportsbooks.list, events.list, account.me, stream.odds/opportunities/all/event) all exist in client.py except events.search (see finding). (7) Python conversion utils outputs verified by execution: a2d(-110)=1.909, a2d(150)=2.5, a2p(-110)=0.524, d2a(2.5)=150 — match doc exactly. (8) Python exceptions AuthenticationError/TierRestrictedError/RateLimitedError all exported (__init__.py:26-30). (9) SSE/WS wire event names in docs (snapshot:complete, connected, odds:update, odds:removed, ev:detected, arb:detected, low_hold:detected, ev:expired) match server emit in sse.go/streaming.go/websocket.go — including TS doc line 41 ws.on('odds:update') which matches the server wire format (the SDK's own type union uses odds_update but the WS manager dispatches by raw wire type, so the doc is correct). (10) EV docs fields ev_percentage, fair_probability, devig_method, sharp_book, kelly_percent all match transforms.go transformEV. (11) Arb leg fields odds_american/stake_percent/selection match transforms.go arb leg (Python arb example works). EV/account verified via code (live agent key returned 'error' envelope for /account,/opportunities/* — agent key entitlement-restricted, and /ev returned empty data; code is authoritative). Most en findings replicate ×4 across de/es/pt-BR mirrors." + }, + { + "unit": "examples", + "live_checked": true, + "notes": "VERIFIED CORRECT (no finding): (1) Arb LEG field names are all right — leg emits sportsbook, selection, odds_american, odds_decimal, odds_probability (NOT implied_probability), stake_percent (transforms.go:1062-1071; live LEG KEYS confirm). Arb scanner reads exactly these (lines 74,104,109-111). (2) Arb top-level profit_percent / event_name / event_id / legs all correct (transforms.go:1135-1163; live ITEM KEYS confirm); doc reads opp['profit_percent'], opp['event_name'] correctly. (3) Filter params min_ev (value-betting line 44) and min_profit (arb line 37) are the real SSE/filter param names (pkg/filters/parse.go:173-174, pkg/filters/unknown.go:28; sse.go:471-491). (4) channel=opportunities is valid and is the default channel (sse.go:305-310). (5) api_key= query-param auth on /stream is supported (auth.go:1084-1090) — value-betting's EventSource api_key=... works. (6) EV event name ev:detected is CORRECT (streaming.go:2718-2722) — only the `.data.` nesting in value-betting is wrong. (7) EV REST/SSE item correctly carries event_id/event_name/market_type/confidence (not game_id/game/confidence_score) — confirmed live; the doc's only EV field-name error is `opp.game`. LIVE CHECK: hit GET /api/v1/opportunities/ev and /opportunities/arbitrage on OVH :3003 — both return flat-paginated {data,pagination,updated_at} (NOT meta) and the exact item/leg keys cited. The SSE FRAME shape (no `data` wrapper) was confirmed from code, not live, since opening a stream wasn't cheap; the frame-builder code is unambiguous. All findings replicate ×4 across en/de/es/pt-BR — locale mirrors verified to contain byte-identical code blocks." + }, + { + "unit": "streaming-guides", + "live_checked": false, + "notes": "Verified CORRECT (no finding needed): stream limit = 1 per key for all paid tiers with newer-wins displacement, 0 for free (tiers.go:167-174 defaultMaxConcurrentStreams; effectiveMaxStreams). single-connection.mdx L12 + overview.mdx requirements table both correctly say '1 (newer-wins displacement)' and NOT 10. single-connection.mdx L167 correctly states displacement = close code '4001 displaced by newer session' on WS. 256KB snapshot frame cap (websocket.go:153 wsSnapshotFrameMaxBytes) and 300-items-per-opp-snapshot-frame (websocket.go:91 wsOpportunityChunkSize) both match websocket.mdx L109. Client-side max-incoming-size guidance (Python websockets 1MB default fine; below 256KB -> 1009 kill-loop) is correct (SetReadLimit at websocket.go:3691 caps INBOUND client msgs at 8192, separate concern; outbound frames capped 256KB). Heartbeat every 30s for both SSE+WS (sse.go:997/1125; websocket.go:64 wsHeartbeatTick=30s) matches overview.mdx L46/L219 and websocket.mdx L120. Ping keepalive client->server {type:'ping'} -> server {type:'pong'} confirmed (websocket.go:3800-3801); 25s interval is a fine client choice (server pong-wait=120s, websocket.go:59). Message type names connected/subscribed/opportunities_snapshot/initial/snapshot:complete/odds:update/odds:removed/ev:detected/heartbeat all match the wire. connected ack DOES carry tier+features+channels+global_seq (websocket.go:5844-5862). resume via resume=true&from_seq=N over a replay buffer is real (websocket.go:3225-3237, replay.go). websocket.mdx L163-165 1006-is-library-generated callout is correct. min_profit default 0.5 is correct (pkg/filters/defaults.go:40). overview.mdx + single-connection.mdx had NO incorrect close-code/limit claims. Could not do a live WS/SSE read: dev SHARPAPI_KEY returns invalid_api_key against prod :3003 streaming (likely not entitled for streaming on prod), so all findings are code-grounded against the origin/main worktree." + }, + { + "unit": "asyncapi", + "live_checked": false, + "notes": "Audited public/asyncapi.yaml (710 lines) against /tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/websocket.go (route/frame source of truth) + pkg/filters/parse.go. No live WS read performed (a WS upgrade handshake is not cheap via curl on :3003; all findings are grounded in code line cites which is authoritative for wire shapes). VERIFIED CORRECT against code: (1) Subscribe client->server input shape — channels enum {ev,arbitrage,middles,low_hold} accepted plus the alias 'opportunities'; filters keys sports/sportsbooks/leagues/markets/eventIds(camelCase) ARE all accepted by pkgfilters.ParseJSON (parse.go:237-273, eventIds at :273), and min_ev/min_profit parse. (2) Ping {type:ping} -> pong reply (websocket.go:3801). (3) connected carries stream_id, tier, features, channels(null-for-all via wsChannelsList), books{max,allowed} (websocket.go:5844-5855); BookAllowance max=-1 unlimited semantics correct. (4) Close codes 4001/4003 exist, NO 4029 (websocket.go:156,164,3434,3467) — spec does NOT itself assert 4029 so no finding there. (5) odds:removed uses top-level source+ids (websocket.go:818,1018) matching spec OddsRemoved. (6) Heartbeat exists ~30s (websocket.go:2236). (7) ExpiredIds.expired is an array of strings (correct element type) — only its NESTING is wrong. Spec is a single shared file (not locale-mirrored under content/en), so all findings are spec-global, not ×4." + }, + { + "unit": "i18n", + "live_checked": true, + "notes": "VERIFIED CORRECT (no bug): (1) Page set is complete — all 4 locales have the identical 35 api-reference pages + identical top-level structure (56 .mdx each); no missing pages. (2) _meta.js navigation keys match EN exactly in all locales (no page-registration drift). (3) The 9a9af03 fixes that WERE meant for all locales landed correctly: Bet365 live tier = Hobby in all 4 (live-vs-prematch.mdx:123); SSE channel/channels CSV-collapse note present in all 4 stream.mdx; is_main_line vs market=main note present in all 4 odds-best.mdx. (4) Machine-readable values correctly left untranslated everywhere: error codes (tier_restricted ×17 files/locale, required_tier ×4, too_many_streams), pagination keys (next_cursor/has_more/count/total), tier slugs (free/hobby/pro/sharp/enterprise), devig 'power' value (wrong case but not translated). (5) 401 'unauthorized' code is documented CORRECTLY in all locales as the Bearer admin/monitoring code distinct from invalid_api_key (authentication.mdx:145) — not the reality-brief concern. (6) Localized pages correctly do NOT hardcode /en/ link prefixes (0 in each locale; Nextra handles locale routing) — no cross-locale link rot. (7) The `steam` feature and /api/v1/injuries endpoint are absent from ALL locales INCLUDING EN — that is an EN-origin content gap (out of i18n scope), not an i18n divergence; the grep 'steam'/'injuries' hits are coincidental prose matches (late steam / injuries-in-prose).\n\nREPLICATION FACTOR: Two distinct error classes. (A) EN-origin systemic bugs replicate IDENTICALLY ×4 into the locales: 4029 close code, devig_method 'power' lowercase, arb implied_probability — fixing EN must also touch ×3 locale files. (B) i18n-specific gaps where EN received post-translation updates (commits 46e7175, 9a9af03, 95660f1, 1987219, the May-2026 numerical_id/nested-ref rollout) that were back-ported UNEVENLY or not at all — 14 of 35 api-reference pages diverge (EN longer), plus event-matching (215 vs 141) and streaming/overview (250 vs 232). The locales were translated at snapshot 9d35b71 and have drifted ~5-6 substantive commits behind. ES is the most stale locale (missed odds-comparison/odds-batch nested-ref that de/pt-BR got; pinnacle page only 60 lines).\n\nLIVE CHECK: EV and arb live reads returned no items (off-peak), so field casing could not be observed live; /teams returned invalid_api_key (agent key not accepted on that path). Go source under sago-main is authoritative for all field-name/casing/close-code findings. Live did confirm the 401 error envelope shape: {error:{code:'invalid_api_key',docs:'https://sharpapi.io/docs/authentication',message:...}} — matching the correct auth docs URL." + }, + { + "unit": "gamestate", + "live_checked": true, + "notes": "Verified CORRECT against /tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main + live OVH :3003 GET /api/v1/gamestate/baseball (22 live events): (1) Routes /api/v1/gamestate and /api/v1/gamestate/:sport exist (routes.go:97-98). (2) Access = game_state ADD-ON ($79) OR Enterprise via gameStateAddonGate (tiers.go:509) — NOT plain-Enterprise-only; docs Authentication section is correct. (3) 403 body shape error.{code:tier_restricted, message, docs:https://sharpapi.io/pricing, tier, addon:game_state} matches docs (tiers.go:535-543). (4) Envelope {data:{:{:{...}}}, updated_at} confirmed (endpoints.go:1464-1467, 1572-1574) and live (top keys = data, updated_at). (5) home_team/away_team/sport/league/home_score/away_score/is_live/primary_book/book_count all present live. (6) stale + aggregator_stale present-only semantics correct (endpoints.go:1409). (7) Streaming: BOTH SSE and WS emit gamestate:snapshot (initial) then gamestate:update (deltas) + gamestate:removed — sse.go:1217, websocket.go:1967/1987/5565-area; docs streaming section accurate incl. the WebSocket-add-on-also-required note (sse.go:332). (8) ?sport= query param and CSV filtering work (endpoints.go:1332-1352). Live key used was sk_int_ (internal/enterprise) so 403 path not exercised live, but code path is unambiguous. Locale note: only en page audited; de/es/pt-BR mirror the same content so most findings replicate x4." + }, + { + "unit": "census-v2", + "live_checked": true, + "notes": "VERIFIED CORRECT (no finding): (1) /futures and /futures/odds ARE in openapi.json and documented in enterprise.mdx (Enterprise-gated per tiers.go:68); there is no dedicated futures.mdx but enterprise.mdx covers both. (2) /splits and /splits/history are in openapi.json and have splits.mdx (Pro+ per tiers.go:54). (3) All 35 openapi.json paths resolve to real routes (no phantoms; events/{id}/markets, events/{id}/odds, account/keys/{id}/rotate, middles/summary served by catch-all subrouters). (4) /api/v1/health and /api/v1/deeplink/{id} are public (routeAuthNone) and present in spec.\n\nAUTHORITATIVE CUSTOMER-FACING ROUTE SET WITH GATES (origin/main routes.go):\nALL-TIER (featureNone, api-key): /odds, /odds/comparison, /odds/delta, /odds/batch(POST), /odds/best, /odds/export, /deeplinks/batch(POST), /events, /events/:id (+/markets,/odds subroutes), /sportsbooks, /sports, /leagues, /markets, /teams, /mcp(ANY).\nPUBLIC (no auth): /deeplink/:id, /health, /, /llms.txt, /robots.txt, /metrics; plus wrong-path stubs /arbitrage and /ev (helpful-redirect messages).\ninjuries (Enterprise): /injuries. splits (Pro+): /splits, /splits/history. futures (Enterprise): /futures, /futures/odds. closing_line (Pro+): /odds/closing.\nhistory (Enterprise): /odds/historical, /historical/odds/movements, /historical/odds/closing, /historical/opportunities, /historical/opportunities/:id, /historical/summary, /historical/clv.\ngamestate (add-on, enterprise auto): /gamestate, /gamestate/:sport, /stream/gamestate.\nev (Pro+): /opportunities/ev. arbitrage (Hobby+): /opportunities/arbitrage. middles (Pro+): /opportunities/middles, /opportunities/middles/:idOrSummary. low_hold (Pro+): /opportunities/low_hold. dispatch (handler self-gates): /opportunities/.\nstreaming (add-on, enterprise auto): /stream, /stream/odds, /stream/opportunities, /stream/all, /stream/events/. WS: /ws, /ws/ (deferred auth).\naccount (dual auth, Clerk OR api-key): /account, /account/usage, /account/keys (+/:id, /:id/rotate).\nNOT customer-facing (adapterSecret): /monitoring/*, /coverage*, /admin/*, /internal/rate-limit-hits, /priority/bet365.\n\nLIVE (enterprise agent key on OVH :3003): /injuries->200, /odds/export->200, /odds/historical?event_id=x->400 (route reachable), /futures->200, /splits->200. Confirms new routes exist live; spec/docs lag." + } + ], + "verdicts": [ + { + "title": "account/usage response shape is almost entirely fictional", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "Handler endpoints.go:1736-1747 returns {data:{requests_today, rate_limit:{limit, remaining, reset}, active_streams}, updated_at}; reset is a UNIX-seconds int = (minuteBucket+1)*60 (endpoints.go:1719). Doc content/en/api-reference/account-usage.mdx documents fictional {data:{requests:{today,this_week,this_month,limit:{per_minute,remaining,resets_at}},streams:{active,max},period:{...}},meta:{updated_at}} — JSON sample L69-95, JS example data.requests.this_week L41 + data.streams.max, field tables L120-150. Every documented field is absent; example JS/Python throws TypeError/KeyError. Confirmed by ratelimit_test.go:301-312 which asserts the real json tags requests_today/rate_limit.", + "note": "Confirmed. The finding's cited evidence line endpoints.go:1609-1620 is slightly off — that is a different/earlier rate_limit block; the actual accountUsageHandler is at endpoints.go:1690 with the response map at 1736-1747. Substance is exact: real shape = flat data{requests_today, rate_limit{limit,remaining,reset}, active_streams} + top-level updated_at, no requests/streams/period objects, no meta envelope, reset is UNIX seconds. Could not get a 200 from live :3003 (the SHARPAPI_KEY sk_int_ key returned invalid_api_key on the direct backend path), but the handler source is unambiguous and matches the prior audit's documented live observation verbatim. Severity HIGH is correct: total schema fiction on a public all-tiers endpoint with runtime-breaking copy-paste examples, but docs-accuracy not security/data-loss so not CRITICAL. locale_scope: replicates x4 (en + de/es/pt-BR mirrors of account-usage.mdx).", + "unit": "account" + }, + { + "title": "Rotate response envelope wholesale wrong: documented flat data fields do not exist; real uses new_key/old_key nesting", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "/tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/keys.go:831-850 — rotateKeyHandler encodes {success:true, data:{new_key:{id,key,name,tier}, old_key:{id,revoked,expires_at}}, meta:{warning,message}}. Docs at /root/sharpapi.io/docs.sharpapi.io/content/en/api-reference/account-keys.mdx:342-355 sample {data:{key_id,name,key,key_preview,status,rotated_at,previous_key_revoked}, meta:{updated_at}} and examples read data.key at mdx:320 (JS) and mdx:333 (Python).", + "note": "Confirmed against canonical origin/main. Finding cited keys.go:817-837 / pkg/api/keys.go; real file is at repo root (keys.go) and the response-body block is lines 831-850 — content identical to the reality claim. None of key_id/key_preview/status/rotated_at/previous_key_revoked exist at data; no meta.updated_at (meta carries warning+message). New key value is at data.new_key.key, so the JS (mdx:320) and Python (mdx:333) examples reading data.key resolve to undefined/None during a security-critical rotation. Live exercise of rotate not possible (agent X-API-Key returns invalid_api_key — not a Supabase user key — and rotate is destructive; not triggered). Severity HIGH retained: wholesale-wrong envelope + broken examples on a key-rotation flow. locale_scope: en finding replicates x4 in de/es/pt-BR mirrors.", + "unit": "account-keys" + }, + { + "title": "Live GA endpoint /odds/export entirely absent from OpenAPI spec", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "routes.go:71 apiKeyRoute GET /api/v1/odds/export featureNone (no tier gate); live GET /api/v1/odds/export?sport=baseball&limit=1 -> 200 {\"id\":\"237021539369541\",\"sportsbook\":\"fliff\",...,\"odds_american\":115,\"odds_decimal\":2.15,\"odds_probability\":0.4651,...}; bogus /api/v1/odds/nonexistent_xyz -> 404 (proves route registration); docs public/openapi.json (v2.1.0) has 35 paths, NO /odds/export (substring grep 'export' across paths = []).", + "note": "Endpoint-absent-from-spec claim fully confirmed against origin/main + live; landed after the prior stale checkout. Severity HIGH stands. CAVEAT: the finding's 'any tier including Free can BULK-export' security framing is overstated — featureNone means the ROUTE is reachable by all authed tiers, but the handler still applies the free-tier book whitelist + 60s data delay (odds_export.go:94 comment: '...the free-tier data delay both apply, so this exposes no data the caller [couldn't otherwise get]'; tier defaults to 'free' at odds_export.go:176-180). So it's a real spec-coverage gap, not an entitlement bypass.", + "unit": "census-openapi" + }, + { + "title": "Live GA endpoint /injuries entirely absent from OpenAPI spec", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "routes.go:81 apiKeyRoute GET /api/v1/injuries TierFeature \"injuries\" (Enterprise-only, gated via gateForFeature->tierGateMiddleware); live GET /api/v1/injuries?sport=baseball -> 200 {\"data\":[{\"player\":\"A.J. Puk\",\"player_position\":\"RP\",\"espn_player_id\":\"36201\",\"espn_team_id\":\"29\",\"team\":\"Arizona Diamondbacks\",\"status\":\"60-Day-IL\",\"return_date\":\"2026-08-10\",...}]}; docs public/openapi.json NO /injuries (grep 'injur' = []); no injuries doc page in content/en/api-reference/ nor _meta.js.", + "note": "Confirmed real Enterprise endpoint with real MLB injury data, zero machine-spec AND zero MDX coverage. HIGH stands.", + "unit": "census-openapi" + }, + { + "title": "Historical-family endpoints (clv, odds/closing, odds/movements, opportunities, summary) missing from OpenAPI spec", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "routes.go:88-94 register /odds/historical, /historical/odds/movements, /historical/odds/closing, /historical/opportunities[/:id], /historical/summary, /historical/clv all gated TierFeature \"history\" (Enterprise); live: /historical/clv -> 200 {\"data\":{\"date_range\":{...},\"group_by\":\"sportsbook\",\"groups\":[{\"group_key\":\"sx_bet\",\"total_graded\":1038,\"win_rate\":0.531,\"avg_clv_percent\":-0.809,...}]}}; /historical/odds/movements, /historical/summary, /historical/opportunities all -> 200; docs public/openapi.json grep 'historical'/'clv'/'movement' = [] (only summary hit is unrelated /opportunities/middles/summary).", + "note": "All six registered + live, all absent from machine spec. Only 2 of 6 have MDX pages (_meta.js:76 historical-clv, :77 historical-odds-closing). HIGH stands. Note routes.go also has /odds/historical (line 88, history-gated) — the prior finding listed odds/closing under historical but odds/closing is the SEPARATE Pro+ closing_line route (line 87), covered by finding 4; the true history-family count is these 6 plus /odds/historical.", + "unit": "census-openapi" + }, + { + "title": "/odds/closing (Closing Line, Pro+) missing from OpenAPI spec despite having a doc page", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "routes.go:87 apiKeyRoute GET /api/v1/odds/closing TierFeature \"closing_line\" (Pro+); live GET /api/v1/odds/closing?sport=baseball -> 400 {\"error\":{\"code\":\"validation_error\",\"message\":\"event_id query parameter is required\"}} (real handler validating params = registered route, not 404); _meta.js:21 'odds-closing':'Closing Line' + content/en/api-reference/odds-closing.mdx exists (6429 bytes); docs public/openapi.json grep 'closing' = [] (path absent).", + "note": "Confirmed: documented (MDX page + _meta entry) Pro+ endpoint with NO machine-spec path. The gap is real for SDK generators / API explorers that consume openapi.json. HIGH stands — a publicly-documented endpoint missing from the spec is a genuine consistency defect, arguably the most surprising of the four since a human doc page already exists. locale_scope: the OpenAPI spec is a single file (public/openapi.json), so the spec gap is global/single (not x4); only the MDX pages mirror per-locale.", + "unit": "census-openapi" + }, + { + "title": "Reference Data endpoints documented as public (Auth: No) but require an API key", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "routes.go:76-80 — all five (/sportsbooks,/sports,/leagues,/markets,/teams) registered via apiKeyRoute; apiKeyRoute always prepends authMiddleware (routes.go:192 `chain := []func...{authMiddleware}`, Auth: routeAuthAPIKey at :205). No publicRoute alias exists for any of them (publicRoute only at routes.go:237, used elsewhere e.g. /health). Doc claims otherwise: overview.mdx:93 '### Reference Data (Public)' and overview.mdx:97-101 list all five with Auth 'No', Min Tier '-'. LIVE: ssh ovh curl http://localhost:3003/api/v1/sports (no key) -> HTTP 401 {\"code\":\"missing_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\"}; same path WITH valid X-API-Key -> HTTP 200 {data:[...]}. locale_scope: en page; de/es/pt-BR mirrors replicate (×4).", + "note": "Fully confirmed in origin/main and live. CRITICAL severity stands: docs tell consumers these are key-free public endpoints, but every call 401s without a key — guaranteed integration failure for anyone following the spec.", + "unit": "conventions-overview" + }, + { + "title": "overview error table: retry_after labeled 'Seconds until retry' but is actually a Unix-ms timestamp", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "ratelimit.go:166-177 — comment `// Match Node.js format: retry_after is Unix ms timestamp`; resetMs := (minuteBucket*60 + 60) * 1000 (line 167); 429 JSON envelope emits BOTH \"retry_after\": resetMs (line 176, Unix-ms timestamp) AND \"retryAfter\": retryAfter (line 177, seconds-until-reset computed at line 153 as resetTime-now.Unix(), floored to 1). Doc: overview.mdx:201 '| `error.retry_after` | Seconds until retry is allowed (optional — included on `rate_limited` errors) |' — documents the snake_case ms-timestamp field as a seconds duration, and never mentions the camelCase retryAfter seconds field that actually holds the duration.", + "note": "Confirmed in origin/main. A client sleeping retry_after ms (per docs) would sleep ~1.78e12 ms (decades); the real seconds value is in the undocumented retryAfter field. HIGH is correct — materially misleading but only manifests under 429 rate-limiting, not on every request. locale_scope: en page + de/es/pt-BR mirrors (×4).", + "unit": "conventions-overview" + }, + { + "title": "Docs claim /deeplinks/batch requires Hobby+ but route has NO tier gate (all authenticated tiers, incl. Free)", + "verdict": "adjusted", + "corrected_severity": "MEDIUM", + "evidence": "routes.go:73 `apiKeyRoute(\"POST\", \"/api/v1/deeplinks/batch\", ..., featureNone, true, deeplinksBatchHandler)`; tiers.go:560 `featureNone = \"\"`; tiers.go:593-607 gateForFeature(featureNone)->(nil,true) so no gate added; routes.go:192-198 chain = [authMiddleware, rateLimitMiddleware] only when gate==nil; main.go:8616-8675 deeplinksBatchHandler does no tier/feature check. Docs: content/en/api-reference/deeplinks.mdx:20 'Available to **Hobby tier and above**.' live: POST /api/v1/deeplinks/batch authed->200, unauthed->401 (only API-key gate, no tier gate).", + "note": "Reality confirmed: route is API-key-only with no tier gate, so Free tier can call it — docs claim of Hobby+ is wrong. Prior finding cited literal \"\" but origin/main now uses the named constant featureNone (==\"\"), and the apiKeyRoute signature dropped the explicit middleware-list arg (gate is derived internally) — same effect, no tier gate. Severity adjusted HIGH->MEDIUM: docs are MORE restrictive than reality (under-promise, not over-promise), so no user is denied a promised capability and there is no billing/entitlement leak in the dangerous direction; it is a documentation-accuracy gap that may cause wrongful upsell perception. Could not test a Free-tier key directly (only an enterprise key available) but the route-chain analysis is conclusive that tier is never inspected. locale_scope: en finding replicates x4 (de/es/pt-BR mirror the same line 20).", + "unit": "deeplinks" + }, + { + "title": "Document the REQUIRED future_id parameter on GET /api/v1/futures/odds", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "CODE: sago-main/futures.go:166-179 futuresOddsHandler — `futureID := q.Get(\"future_id\"); if futureID == \"\" { WriteHeader(400); error{code:ValidationError, message:\"Missing required parameter: future_id\"} }`. routes.go:85 registers GET /api/v1/futures/odds (feature \"futures\", Enterprise). LIVE: GET /api/v1/futures/odds?limit=2 -> {\"error\":{\"code\":\"validation_error\",\"message\":\"Missing required parameter: future_id\"}} (HTTP 400); with ?future_id=test -> wrapped {data,meta,pagination,success} 200. DOCS: content/en/api-reference/enterprise.mdx:39-52 — bare example `GET /api/v1/futures/odds` + Query Parameters table lists only sport, league, sportsbook, market, limit, offset; `future_id` never mentioned (grep count 0).", + "note": "Fully confirmed across code, live API, and docs. The documented bare call returns HTTP 400 for any reader who copy-pastes it; future_id is the single mandatory param. Severity HIGH is correct — non-functional documented example on an Enterprise-only endpoint, but no security/data-integrity impact so not CRITICAL. locale_scope: replicates x4 — de/es/pt-BR enterprise.mdx also omit future_id (grep count 0 each). Minor doc-extra note (not the finding): docs also list a `market` param the handler does not parse.", + "unit": "enterprise-futures" + }, + { + "title": "events.mdx Success response uses {data, meta:{count,total,pagination,filters}} envelope; reality is {data, pagination, updated_at}", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "docs: content/en/api-reference/events.mdx:268-284 (response sample wraps list in \"meta\":{count,total,pagination:{limit,offset,has_more,next_offset},updated_at,filters:{sport,league}}); examples read meta at events.mdx:105-106 (JS `const {data, meta}` / `meta.total`,`meta.count`) and events.mdx:123 (Python `data['meta']['total']`). reality: main.go:6896-6903 eventsHandler encodes a FLAT envelope {\"data\":page, \"pagination\":Pagination{Limit,Offset,Count,Total,HasMore,NextOffset,Warning}, \"updated_at\":getStoreTimeISO()} — no `meta`, no nested pagination, no filters echo. JS `meta.total` is undefined; Python `data['meta']['total']` raises KeyError. live: missing-key 401 confirmed reachability but key invalid this session so envelope not re-fetched live; code is authoritative.", + "note": "Confirmed. Real encode is at main.go:6896-6903 (prior audit cited stale main.go:5896). Both list code examples plus the response sample are broken (meta.total undefined / KeyError). Severity CRITICAL stands. locale_scope: replicates x4 — content/{de,es,pt-BR}/api-reference/events.mdx each carry the same `meta` envelope (grep count 2).", + "unit": "events" + }, + { + "title": "events-by-id.mdx Success response uses {data, meta:{event_id}} envelope; reality is {data, updated_at}", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "docs: content/en/api-reference/events-by-id.mdx:190-192 shows {data:{...}, \"meta\":{\"event_id\":\"nba_celtics_lakers_2026-02-08_b3\"}}; events-by-id.mdx:90 JS `const {data, meta} = await response.json();`. reality: main.go:9281-9284 eventDetailHandler encodes {\"data\":ev, \"updated_at\":getStoreTimeISO()} — no `meta`, no event_id echo. (Sub-route /events/{id}/odds at main.go:9179-9222 likewise emits {data,updated_at[,filter_warning]}, no meta.) JS `meta` is undefined; `meta.event_id` undefined.", + "note": "Confirmed. Real encode at main.go:9281-9284 (prior audit cited stale main.go:8149). meta/event_id are phantom. Severity HIGH stands. locale_scope: replicates x4 — content/{de,es,pt-BR}/api-reference/events-by-id.mdx each carry the `meta` envelope (grep count 2).", + "unit": "events" + }, + { + "title": "401 error examples use code 'unauthorized'; reality emits 'missing_api_key' / 'invalid_api_key'", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "docs: content/en/api-reference/events.mdx:293 `\"code\":\"unauthorized\"` and content/en/api-reference/events-by-id.mdx:213 `\"code\":\"unauthorized\"`. reality: apiKey middleware returns missing_api_key/invalid_api_key — auth.go:1163 errcodes.MissingAPIKey; auth.go:1206,1277,1366 errcodes.InvalidAPIKey; pkg/errcodes/errcodes.go:21 InvalidAPIKey=\"invalid_api_key\", :25 MissingAPIKey=\"missing_api_key\", :33 Unauthorized=\"unauthorized\". errcodes.Unauthorized is used ONLY in monitoring.go:67 (Bearer) and admin.go:43 (admin), never on these apiKey endpoints. live: GET /api/v1/events?limit=1 no key -> {\"error\":{\"code\":\"missing_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"API key required...\"}}; bad key -> {\"error\":{\"code\":\"invalid_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"Invalid API key\"}}.", + "note": "Confirmed via origin/main code AND live. `unauthorized` is never emitted for these apiKey endpoints; docs also use a stale docs URL (https://docs.sharpapi.io/en/authentication) vs live https://sharpapi.io/docs/authentication, and a generic message vs the real per-case messages. Severity HIGH stands. locale_scope: replicates x4 — both pages in {de,es,pt-BR} carry the `unauthorized` 401 (grep count 2 each).", + "unit": "events" + }, + { + "title": "events-odds: documented nested `odds:{american,decimal,odds_probability}` object does not exist — response is flat", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "CODE: sago-main/main.go:622-624 (Odds struct: OddsAmerican `json:\"odds_american\"`, OddsDecimal `json:\"odds_decimal\"`, OddsProbability `json:\"odds_probability\"` — flat, no nested `odds`). DOC claims nested object at content/en/api-reference/events-odds.mdx:61-63 (schema `odds.american`/`odds.decimal`/`odds.probability`) and L218-222/233-237/248-252/263-267/278-282 (example nests `\"odds\":{...}`). LIVE: /events/mlb_rockies_twins_2026-06-26_b3/odds -> item keys include odds_american/odds_decimal/odds_probability, 'nested odds object present: False'.", + "note": "Confirmed at code + live + doc. Prior audit cited main.go:585-601; real Odds struct fields are main.go:622-624 (line numbers shifted). Note the doc example field name is `odds_probability` (not `odds.probability`) inside the nested object, so doc is internally inconsistent too. locale_scope: replicates x4 (en + de/es/pt-BR mirrors).", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: response envelope documented with `meta` + pagination; real envelope is `{data, updated_at}` with no meta/pagination", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "CODE: sago-main/main.go:9179-9182 — isOdds branch builds `resp := map[string]interface{}{\"data\": result, \"updated_at\": getStoreTimeISO()}` (only optional `filter_warning` added when empty), encoded at :9222. No meta/count/total/pagination/filters. DOC claims `meta{count,total,pagination{limit,offset,has_more,next_offset},updated_at,filters}` at content/en/api-reference/events-odds.mdx:286-301; examples read meta.total (L85,98) + meta.pagination.has_more/next_offset (L166-171,194-196). LIVE: TOP KEYS = ['data','updated_at'] only.", + "note": "Confirmed code+live+doc. Handler is eventDetailHandler isOdds branch (main.go:9124-9223), not the line the prior audit cited (8086-8090, which is loadMiddlesCached — wrong function, but conclusion correct). Pagination while-loops in examples never terminate (meta.pagination undefined). locale_scope: x4.", + "unit": "events-odds-markets" + }, + { + "title": "events-odds: documented query params sportsbook/market/limit/offset are silently ignored (param-noop)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "CODE: sago-main/main.go:9145-9176 — isOdds branch reads only X-Tier (allowedBooksSet/dataDelayCutoffForTier) and iterates cached.Books for the event; never calls r.URL.Query() for sportsbook/market/limit/offset. DOC lists those 4 params at content/en/api-reference/events-odds.mdx:31-36 and passes them in examples (L108,146-151). LIVE: ?limit=1 returned 1514 items (full set); ?sportsbook=draftkings returned 23 distinct books (filter ignored); ?market=moneyline ignored.", + "note": "Confirmed code+live. Server-side book visibility is by TIER entitlement only, not the sportsbook param. Silent wrong-data (returns far more than requested) — HIGH is correct.", + "unit": "events-odds-markets" + }, + { + "title": "events-markets: documented `selections[]` sub-array and Selection Object do not exist", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "CODE: sago-main/endpoints.go:843-849 (MarketResult{MarketType,MarketLabel,SelectionCount int,BookCount int,Books []string}) — no selections array; only selection_count (int). DOC claims `selections` array (content/en/api-reference/events-markets.mdx:44) + full Selection Object table L46-54 + nested selections in example JSON L114-157 + examples iterate market.selections (L76-79,95-97). LIVE: market keys = ['book_count','books','market_label','market_type','selection_count'], 'has selections array: False'.", + "note": "Confirmed code+live+doc. Prior audit cited endpoints.go:746-752; real struct at endpoints.go:843-849 (lines shifted). JS `for(const sel of market.selections)` throws (undefined not iterable). locale_scope: x4.", + "unit": "events-odds-markets" + }, + { + "title": "events-markets: response wrapped in `{success:true,...,meta}`; real envelope is `{data, updated_at}`", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "CODE: sago-main/endpoints.go:875-879 — eventMarketsHandler encodes `{\"data\": result, \"updated_at\": storeTimeISO(snap)}` only; no success, no meta. DOC shows `{success:true,data,meta{event_id,count,updated_at}}` at content/en/api-reference/events-markets.mdx:107-165; JS destructures `const {data, meta}` (L71). LIVE: TOP KEYS = ['data','updated_at'], 'has success key: False', 'has meta key: False'.", + "note": "Confirmed code+live+doc. Prior audit cited endpoints.go:778-781; real encode at 875-879. locale_scope: x4.", + "unit": "events-odds-markets" + }, + { + "title": "events-markets: field named `type`; real field is `market_type` (+ undocumented `market_label`, `books[]`)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "CODE: sago-main/endpoints.go:844 `MarketType string json:\"market_type\"`, :845 `MarketLabel json:\"market_label\"`, :848 `Books []string json:\"books\"`. DOC field is `type` at content/en/api-reference/events-markets.mdx:41; examples read market.type/market['type'] (L75,94); example JSON uses type values point_spread/total_points (L120,129). LIVE market: market_type='game_prop', market_label='Game Prop', books=['ballybet','caesars'] — no `type` key. Values are canonical snake_case (game_prop, etc.), not the doc's point_spread/total_points.", + "note": "Confirmed code+live+doc. Prior audit cited endpoints.go:747-751; real lines 844-848. market.type reads undefined; doc's market_type value vocabulary (point_spread/total_points) is also wrong vs canonical (spread/total). locale_scope: x4.", + "unit": "events-odds-markets" + }, + { + "title": "Entire health response envelope is fabricated — real shape is flat {status,valkey,newest_odds_age_s,version,pinnacle?}, not {status,version,timestamp,checks:{...}}", + "verdict": "confirmed", + "evidence": "sago-main/main.go:9350-9371 (resp map keys: status, valkey, newest_odds_age_s, version, +pinnacle if cadence, +source_health if enabled — NO checks/timestamp). Docs at /root/sharpapi.io/docs.sharpapi.io/content/en/api-reference/health.mdx:65-129 claim {status,version,timestamp,checks:{redis,data,schema}}. LIVE: ssh ovh :3003/api/v1/health -> {\"newest_odds_age_s\":0,\"pinnacle\":{...},\"status\":\"ok\",\"valkey\":\"ok\",\"version\":\"9a3294e\"} — no checks/timestamp.", + "corrected_severity": "CRITICAL", + "note": "Confirmed against origin/main + live. Finding's main.go line cite (8218) was stale; real handler is healthHandler at main.go:9303, resp built 9350-9371. Docs health.mdx line cites (65-129) are exact. One omission in the finding: the real response can ALSO carry an optional `source_health` field (main.go:9368-9370, gated on SOURCE_HEALTH_ENABLED) and the live `pinnacle` object is a rich per-sport cadence map, not a small one — additions, not contradictions. Core verdict stands. locale_scope: en doc replicates across de/es/pt-BR (4 health.mdx mirrors confirmed present).", + "unit": "health" + }, + { + "title": "JS and Python example code destructures health.checks — throws / prints undefined against the real flat response", + "verdict": "confirmed", + "evidence": "health.mdx:36 `for (const [name, check] of Object.entries(health.checks))` (health.checks undefined -> TypeError); health.mdx:54 `for name, check in health['checks'].items()` and 55-56 `check['status']`/`check.get('message')` (KeyError: 'checks'); Uptime Dashboard health.mdx:251-256 same KeyError; Pre-Request Health Verification health.mdx:218-234 only reads health.status (does NOT touch .checks, so it actually works). Real response (main.go:9350-9371 + live) has no `checks` key.", + "corrected_severity": "HIGH", + "note": "Confirmed. JS snippet (36-40) and both Python snippets (54-56 and Uptime Dashboard 251-256) break exactly as claimed. Minor correction to the finding's scope: the 'Pre-Request Health Verification' snippet (health.mdx:218-234) does NOT destructure .checks — it only reads health.status, so it does not fail; the finding overstated by lumping it in. The two truly-broken Python paths are 54-56 and 251-256, plus the JS at 36-40. Net: still HIGH — copy-pasteable example code throws. locale_scope: replicates ×4 across locales.", + "unit": "health" + }, + { + "title": "OpenAPI getHealth schema is wrong — requires fabricated [status,version,timestamp,checks] and defines a HealthCheck schema that does not exist", + "verdict": "confirmed", + "evidence": "public/openapi.json /health get.responses.200.schema: required=[status,version,timestamp,checks], checks.properties.{redis,data,schema} each $ref #/components/schemas/HealthCheck; description='Returns service health status, Redis connectivity, data freshness, and schema compatibility'; example version='v1', '28 sportsbook snapshots available', 'All 14 required fields present'. components.schemas.HealthCheck = {status,latency_ms,message}. Real body (main.go:9350-9371 + live) is flat {status,valkey,newest_odds_age_s,version,pinnacle?} — version is a SHA ('9a3294e'), not 'v1'.", + "corrected_severity": "HIGH", + "note": "Confirmed against /root/sharpapi.io/docs.sharpapi.io/public/openapi.json (the canonical spec; this is a docs artifact, not under sago-main). Every specific sub-claim verified: required list, $ref HealthCheck, description text, both example strings, version='v1' vs real SHA. ONE wording nit in the finding's TITLE: it says 'defines a HealthCheck schema that does not exist' — the HealthCheck schema DOES exist in components.schemas (I printed it: {status,latency_ms,message}). The accurate statement is that the spec defines HealthCheck sub-objects the REAL response never returns. The finding's reality/evidence body is correct; only the title phrasing is loose. Substantive verdict = confirmed, severity HIGH (spec drives codegen/SDK clients to expect a body that never arrives). Also note: 503 schema in openapi.json declares only {status} — real down body adds valkey/newest_odds_age_s/version too, a further (unflagged) drift.", + "unit": "health" + }, + { + "title": "historical-odds-closing declares Sharp tier; endpoint is history-gated = Enterprise-only", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "routes.go:90 apiKeyRoute(\"GET\",\"/api/v1/historical/odds/closing\",...,\"history\",true,historical.OddsClosingHandlerBacked); tiers.go:68 enterprise is the ONLY tier whose Features include \"history\"; tiers.go:439-447 requiredTierForFeature iterates free->enterprise so \"history\" resolves to enterprise; live: enterprise key GET /historical/odds/closing -> 200 meta.tier_window_days=\"unlimited\". Doc claims Sharp at historical-odds-closing.mdx:2 (frontmatter), :16, :25, :39.", + "note": "Confirmed. Gate is 'history' (Enterprise), not Sharp. locale_scope: en canonical; de/es/pt-BR mirror this page so the error replicates x4.", + "unit": "historical" + }, + { + "title": "closing_lines feature does not exist (only closing_line singular, a different Pro+ endpoint)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "grep '\"closing_lines\"' across *.go (origin/main worktree) returns NO tier-feature match; every 'closing_lines' hit (mcp.go:168/267/367 tool name, metrics.go:637/645, closing.go ClickHouse column refs) is a table/column/tool name, never a Features[] entry. The real feature is 'closing_line' singular at tiers.go:43 (pro),:54 (sharp),:68 (enterprise), which gates the DIFFERENT /api/v1/odds/closing route (routes.go:87). Docs cite 'closing_lines' at historical-odds-closing.mdx:16,:25,:223.", + "note": "Confirmed. The history-closing endpoint is gated by 'history', not by any 'closing_lines' feature; 'closing_lines' is the ClickHouse table only.", + "unit": "historical" + }, + { + "title": "closing 403 wrong-tier example body is wrong on every field", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "tiers.go:472-485 writeTierRestricted emits message=fmt.Sprintf(\"This endpoint requires %s tier or higher\",capitalize(requiredTier)) (tiers.go:479), docs=\"https://sharpapi.io/pricing\" (:480), tier= (:481), required_tier=requiredTier (:482); capitalize at tiers.go:700-705 -> \"Enterprise\"; requiredTierForFeature(\"history\")=enterprise. Docs example historical-odds-closing.mdx:220-227 shows message=\"The 'closing_lines' feature requires Sharp or higher.\", required_tier=\"sharp\", docs=\"https://docs.sharpapi.io/en/pricing\", and omits the tier field. All four differ.", + "note": "Confirmed. Prior audit's line cites (tiers.go:329-343/:336/:449-454) are stale; in origin/main the body is writeTierRestricted at tiers.go:472-485 + capitalize at :700-705 — substance identical.", + "unit": "historical" + }, + { + "title": "closing data.events[].books is an array in docs; reality is an object keyed by book (no 'book' field)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "closing.go:172 ClosingSnapshot.Books map[string]BookClosing json:\"books\"; closing.go:155-161 BookClosing has Odds []ClosingOdd json:\"odds\" (:156), CapturedAt, SecondsBeforeKickoff, CaptureTrigger, IsFinal -- NO Book field; historical.go:319 the route handler returns these snaps as data.events. LIVE (enterprise key, date=2026-06-26 baseball mlb, 37 events): event.books type=dict; book entry keys=['odds','captured_at','seconds_before_kickoff','capture_trigger','is_final']. Docs show books as an array of {book,captured_at,lines} at historical-odds-closing.mdx:124-187 + field table :270-274.", + "note": "Confirmed by both code and live. Also note the docs example event carries 'first_captured_at' (:123,:265) but live event uses 'captured_at' (no first_captured_at field) — same page, additional drift.", + "unit": "historical" + }, + { + "title": "closing example code iterates books as array and reads .book — throws / errors when run", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "JS historical-odds-closing.mdx:68-74 does 'for (const book of event.books){ book.lines.find(...); book.book }' and Python :93-99 does 'for book in event[\"books\"]: for line in book[\"lines\"]: ... book[\"book\"]'. Reality: closing.go:172 Books is map[string]BookClosing (JSON object) and LIVE event.books is a dict -> JS for...of over an object throws TypeError (not iterable); Python iterates string keys then book[\"lines\"] raises TypeError (string indices). Field is 'odds' (closing.go:156) not 'lines', and the key is the book name (no '.book').", + "note": "Confirmed CRITICAL: documented copy-paste sample code is non-runnable against the live response. locale_scope: replicates across de/es/pt-BR mirrors.", + "unit": "historical" + }, + { + "title": "markets.mdx invents description/hasLine/examples/selection_count fields that do not exist in the API", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "cache.go:2238-2244 (CachedMarket struct = ID,Name,NumericalID(omitempty),EventCount,Sports ONLY). live: /markets data[0] keys = [event_count,id,name,numerical_id,sports] (377 items) — no description/hasLine/examples/selection_count. Doc markets.mdx invents them in example JSON (L85-89,95-99,etc), schema table (L195-199), and the JS example reads m.examples.length + m.hasLine (L50) while Python reads market['hasLine']/market['examples'] (L65,68-69) — both throw at runtime on the real response.", + "note": "Fully confirmed by both origin/main code and live API. Active breakage: the documented JS/Python sample code dereferences fields that are absent, causing TypeError (JS m.examples is undefined) / KeyError (Python market['hasLine']). Severity HIGH is correct. locale_scope: en canonical; de/es/pt-BR mirrors replicate (×4) if they carry the same example/schema/code blocks.", + "unit": "markets-sportsbooks" + }, + { + "title": "sportsbooks.mdx assigns wrong tiers to specific books (betrivers, bet365, kalshi/polymarket)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sportsbooks.go:27 betrivers RequiresTier='hobby' (doc example L149 says 'pro'); sportsbooks.go:33 'Bet365 US' RequiresTier='hobby' and L76 'bet365' is only an alias→'Bet365 US' (no standalone bet365 catalog entry) yet doc L205-216 ships an object id='bet365' tier='pro'; sportsbooks.go:57 kalshi='hobby' (doc example L293 shows null); sportsbooks.go:58 polymarket='hobby' (doc example L280 shows null). live confirms: betrivers=hobby, 'Bet365 US'=hobby (no 'bet365' id returned), kalshi=hobby, polymarket=hobby.", + "note": "Confirmed against source, live, AND the doc's own internal contradiction (example null vs prediction-books table L447-448 Hobby for kalshi/polymarket; example 'pro' vs us-books table L393 Hobby for betrivers). Note the doc's AUTO-generated tier tables (L350,393,447,448) are already CORRECT — only the hand-written example JSON block (L99-294) is stale/wrong. The 'bet365' issue is two errors: the id object doesn't exist in any response (canonical id is 'Bet365 US') AND the tier 'pro' is wrong (actual hobby). Misstates tier access → HIGH correct. locale_scope: en canonical; ×4 across de/es/pt-BR mirrors.", + "unit": "markets-sportsbooks" + }, + { + "title": "Response envelope documented as {success, meta:{...}} but API returns flat {data, pagination, updated_at}", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/main.go:975-999 OddsResponse struct = json {data, removed?, pagination, updated_at, meta?(=OddsResponseMeta{server_time} only), overflow?, ...} — no success/books_available/books_returned/filters. oddsHandler serializes flat at main.go:6005-6013 (resp := OddsResponse{Data:page, Pagination:pag, UpdatedAt:...}). Docs odds.mdx:186-250 show {success:true, data, meta:{count,total,books_available,books_returned,pagination:{...},updated_at,filters}}. Could not re-confirm live (all data API keys in .env stale → invalid_api_key), but code is unambiguous.", + "note": "Prior audit cited main.go:5029/921 — real locations in canonical origin/main worktree are 6005/975. Note main.go is at repo ROOT here, not under cmd/. meta is present in the struct but ONLY as {server_time} for /odds/delta chaining, not the wrapper the docs describe. en finding replicates ×4 (de/es/pt-BR mirrors).", + "unit": "odds" + }, + { + "title": "`fields` (field selection) param is a silent no-op on /odds", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "No `Fields` member exists on filters.Filters (sago-main/pkg/filters/filters.go has MinOdds/MaxOdds/Sort but no Fields); `fields` is not parsed in pkg/filters/parse.go (query or JSON-body paths) and not in recognizedQueryKeys (pkg/filters/unknown.go:14-39) — it would be flagged as an UNKNOWN param on streaming surfaces. No `Fields`/`\"fields\"` read in oddsHandler/fast/multi/group_by (main.go 5598-6713). Docs odds.mdx:38 + 448-470 ('Field Selection') promise it.", + "note": "Stronger than claimed: param is wholly unrecognized, not merely unread. REST /odds silently ignores it (REST handlers don't run UnknownQueryParams). Every row fully serialized. ×4 locales.", + "unit": "odds" + }, + { + "title": "`sort` param is a silent no-op on /odds (results are always chronological)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "oddsHandler always sort.Sort(byOddsChronological(matched)) at sago-main/main.go:5952; fast paths walk pre-sorted snap.OddsIdx indexes. `Filters.Sort` is parsed (pkg/filters/parse.go:200) but first CONSUMED at main.go:6747 (sortParam in eventsHandler at 6714) and in opportunitiesHandler — never inside any odds handler (oddsHandler 5598 / FastPath 6035 / MultiFastPath 6262 / GroupByEvent 6502). Docs odds.mdx:37 + 428-446 ('Sorting') promise it with a Valid-sort-fields list.", + "note": "Prior audit's main.go:5747 for the events sort is now 6747 in this worktree; same semantics. ×4 locales.", + "unit": "odds" + }, + { + "title": "`min_odds` / `max_odds` range filters are silent no-ops on /odds", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "Parsed at sago-main/pkg/filters/parse.go:175-176 (query) and 305-306 (JSON body) into Filters.MinOdds/MaxOdds (filters.go:28-29). Only APPLIED in opportunitiesHandler: hasMinOdds/hasMaxOdds at main.go:7514-7522 and the filter at 7812-7815. No MinOdds/MaxOdds reference anywhere in odds handler family (5598-6713) — only MaxOddsAge (a different param) appears there. Docs odds.mdx:39-40 + 472-484 ('Odds Range Filtering') promise filtering.", + "note": "Prior audit's opportunities line cites (6465-6473/6763-6766) are stale vs this worktree; actual application is at 7514-7522/7812-7815. Behavior identical: /odds returns every row. ×4 locales.", + "unit": "odds" + }, + { + "title": "401 error code documented as `unauthorized`; API emits `missing_api_key` / `invalid_api_key`", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "live (no key) -> {\"error\":{\"code\":\"missing_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"API key required. Pass via X-API-Key header, api_key query parameter, or Bearer token.\"}}; live (bad key) -> {\"error\":{\"code\":\"invalid_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"Invalid API key\"}}. Docs odds.mdx:273-282 claim code=\"unauthorized\", message=\"Invalid or missing API key\", docs=\"https://docs.sharpapi.io/en/authentication\".", + "note": "Verified live on OVH :3003. BOTH the code AND the docs URL are wrong (real auth docs = https://sharpapi.io/docs/authentication). `unauthorized` is reserved for admin/monitoring Bearer auth. ×4 locales.", + "unit": "odds" + }, + { + "title": "Sharp tier book access documented as 'All 32'; reality is capped at 25", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/tiers.go:46-56 sharp{MaxBooks:25, MaxWSBooks:25} with explicit comment 'Previously -1 (unlimited) which broke the \"Choose any 25\" marketing claim'. Only enterprise is unlimited: tiers.go:57-58 {MaxBooks:-1}. Docs odds.mdx:539 mark Sharp='All 32' (and 540 Enterprise='All 32').", + "note": "Confirmed. Sharp is capped at 25, not all books. Secondary inaccuracy not in the finding: Enterprise is shown as a fixed 'All 32' (odds.mdx:540 / frontmatter L2 'across 32 books') but is actually unlimited (-1), and 32 is a stale book count. Finding's Sharp focus is correct. ×4 locales.", + "unit": "odds" + }, + { + "title": "Response envelope is wrapped {success,data,meta} but reality is flat {data,updated_at}", + "verdict": "confirmed", + "evidence": "sago-main/main.go:8603-8609 (json.NewEncoder.Encode of map with keys data{events,missing_events} + updated_at only — no success, no meta); LIVE POST /api/v1/odds/batch -> TOPKEYS ['data','updated_at'], HAS_SUCCESS False, HAS_META False, updated_at top-level, data keys [events,missing_events]. Doc claims success/meta at content/en/api-reference/odds-batch.mdx:124 ('\"success\": true'), :241 ('counts in meta'), :254-255 (meta.requested/meta.found table).", + "corrected_severity": "HIGH", + "note": "Both code and LIVE ground truth confirm the flat {data,updated_at} envelope; docs show the wrapped {success,data,meta} shape that the API never emits. Aside: the docs example also shows max_batch:50 (reality cap is 10) but that is outside this finding's scope. locale_scope: en doc; de/es/pt-BR mirrors likely replicate the same wrong example (x4).", + "unit": "odds-batch" + }, + { + "title": "JS/Python examples destructure phantom meta.found/meta.requested — will throw / print undefined", + "verdict": "confirmed", + "evidence": "Doc content/en/api-reference/odds-batch.mdx:75-76 (`const { data, meta } = await response.json(); ... ${meta.found} of ${meta.requested}`), :105-106 (Python `meta = result['meta']; meta['found']/meta['requested']`), :332 (Dashboard Refresh JS re-destructures meta). LIVE response has NO meta key (TOPKEYS ['data','updated_at']) -> JS meta===undefined so meta.found throws TypeError; Python result['meta'] raises KeyError. Handler emits no meta: sago-main/main.go:8603-8609.", + "corrected_severity": "HIGH", + "note": "Copy-paste customer code in both languages breaks immediately against the real response. Confirmed against both origin/main code and live API. locale_scope: en; localized mirrors (de/es/pt-BR) replicate (x4).", + "unit": "odds-batch" + }, + { + "title": "Wrong response envelope: docs show wrapped {success,data,meta}; handler emits flat-paginated {data,pagination,updated_at}", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "sago-main/main.go:8891-8898 (oddsBestHandler json.NewEncoder.Encode(map{\"data\":page,\"pagination\":Pagination{Limit,Offset,Count,Total,HasMore,NextOffset,Warning},\"updated_at\":RFC3339Nano}) — no success, no meta, no filters echo). DOC content/en/api-reference/odds-best.mdx:96-158 shows wrapped {\"success\":true,\"data\":[...],\"meta\":{count,total,pagination,updated_at,filters}} and JS example L61 `const { data, meta } = ...`. LIVE /odds/best?sport=baseball&limit=2 -> top-level keys ['data','pagination','updated_at']; has success=False; has meta=False; pagination keys [count,has_more,limit,next_offset,offset,total].", + "note": "Confirmed against origin/main code AND live API. Pagination is top-level (not nested under meta), keys are {limit,offset,count,total,has_more,next_offset} (+ optional warning) — note docs omit `count`/`total` from the nested pagination but the real flat pagination has them. `filters` echo not emitted. locale_scope: replicates x4 (de/es/pt-BR mirror the en page). Severity CRITICAL stands — the entire documented Success(200) envelope shape is wrong, breaking the JS destructure example and any client coding to {success,data,meta}.", + "unit": "odds-best" + }, + { + "title": "401 error code documented as 'unauthorized'; real codes are missing_api_key / invalid_api_key", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "DOC content/en/api-reference/odds-best.mdx:172-180 shows 401 body {\"error\":{\"code\":\"unauthorized\",\"message\":\"Invalid or missing API key\",\"docs\":\"https://docs.sharpapi.io/en/authentication\"}}. LIVE GET /odds/best (no key) -> {\"error\":{\"code\":\"missing_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"API key required...\"}}; LIVE GET /odds/best (bad key) -> {\"error\":{\"code\":\"invalid_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\",\"message\":\"Invalid API key\"}}.", + "note": "Confirmed via live API. Real customer-facing 401 codes are missing_api_key (no key) and invalid_api_key (bad key); 'unauthorized' is never returned on this endpoint. SECONDARY mismatch also present: docs `docs` URL = https://docs.sharpapi.io/en/authentication but live = https://sharpapi.io/docs/authentication. locale_scope: x4. HIGH severity appropriate (error-contract mismatch, not a total surface break).", + "unit": "odds-best" + }, + { + "title": "data.books[book] is an object {odds,...} not a ClosingOdd[]; JS/Python examples read the array off the wrong level", + "verdict": "confirmed", + "evidence": "Doc claim: content/en/api-reference/odds-closing.mdx:50-52 (JS `for (const [book, odds] of Object.entries(data.books)) { ...odds.length... }`), :66-67 (Python `for book, odds in snap['books'].items(): ...len(odds)`), :85-115 (JSON example shows bare arrays), :152 (`data.books` = 'Map of book_id -> ClosingOdd[]'). Reality: closing.go:155-161 BookClosing is an object {Odds []ClosingOdd `json:\"odds\"`, CapturedAt, SecondsBeforeKickoff, CaptureTrigger, IsFinal}; handler closing.go:2634 + 2641 always emits `snap.Books[field] = BookClosing{...}` (even the legacy fallback wraps the array). LIVE confirms: /odds/closing?event_id=usa_-_milb_aaa_columbusclippers_omahastormchasers_2026-06-27_b0 -> data.books[book] is a dict with keys [odds, captured_at, seconds_before_kickoff, capture_trigger, is_final], odds is the nested list (len 14). JS `odds.length`=undefined; Python `len(odds)`=5 (the 5 dict keys), not the 14 prices. Examples are silently wrong.", + "note": "Confirmed at stated HIGH. Replicates x4 locales (en/de/es/pt-BR mirror; line numbers near-identical). Same defect in both the field-table description (line 152) and the runnable JS+Python examples.", + "unit": "odds-closing" + }, + { + "title": "BookClosing per-book envelope fields (capture_trigger, is_final, seconds_before_kickoff, per-book captured_at) are undocumented", + "verdict": "confirmed", + "evidence": "Doc: content/en/api-reference/odds-closing.mdx Response Fields table :144-152 and ClosingOdd table :156-166 document only top-level data.captured_at and per-odd fields; no per-book envelope (JSON example :85-115 shows book->array, no envelope). Reality: closing.go:155-161 BookClosing carries CaptureTrigger (consts closing.go:70-81: transition|kickoff|disappearance|evict|backfill), IsFinal (closing.go:160, json `is_final` with NO omitempty -> always emitted), SecondsBeforeKickoff, per-book CapturedAt. LIVE: data.books. -> capture_trigger='evict', is_final=true, seconds_before_kickoff=-3288 (negative, as predicted), captured_at='2026-06-27T00:59:48.538...Z'. These are load-bearing CLV confidence/timing fields returned on every populated book and are entirely absent from the docs.", + "note": "Confirmed at stated HIGH. capture_trigger and is_final directly determine CLV confidence ordering; a customer cannot discover them from the docs. Replicates x4 locales.", + "unit": "odds-closing" + }, + { + "title": "Response envelope is wrong: doc shows {success, data, meta}; reality is {data, pagination, updated_at}", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "sago-main/endpoints.go:255-262 encodes map{\"data\":result,\"pagination\":Pagination{Count:len(result)},\"updated_at\":storeTimeISO(snap)} — no success bool, no meta object. Doc content/en/api-reference/odds-comparison.mdx:99-194 shows {success:true,data:{...},meta:{...}}. LIVE /odds/comparison?event_id=kalshi_fut_kxgolfmajors_ssch26 -> top keys ['data','pagination','updated_at'], has success=False, has meta=False.", + "note": "Confirmed by code AND live. Locale_scope: en doc is canonical source; de/es/pt-BR mirror so replicates x4 if they carry the same JSON.", + "unit": "odds-comparison" + }, + { + "title": "data is documented as a single nested event object; reality is a flat array of comparison entries", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "sago-main/endpoints.go:184 result := make([]comparisonEntry,0,len(groups)); 256 \"data\":result. comparisonEntry (169-175) = {market_type,selection,line,books,book_holds} — no event wrapper, no nested markets[]/selections[]. Doc line 209 says 'data is a single event object (not an array)'. LIVE: data type=list, entry keys=['market_type','selection','line','books'].", + "note": "Confirmed code + live. Replicates x4 across locales.", + "unit": "odds-comparison" + }, + { + "title": "JS and Python examples destructure non-existent fields (data.markets, market.hold, sel.books, sel.best_book, event.event_name) — they throw at runtime", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "Doc lines 57-65 (JS data.markets.forEach, market.hold.best_available, market.selections, Object.entries(sel.books)) and 79-90 (Python event=result['data']; event['event_name']; event['markets']; sel['books'].items()). Reality sago-main/endpoints.go:173 Books []bookEntry (array, line 108-114), 184 data=[]comparisonEntry. LIVE: result['data'] is a list -> Python event['event_name'] raises TypeError (list indices must be integers); JS data.markets is undefined -> .forEach throws.", + "note": "Highest-impact: copy-paste examples crash. Confirmed code + live. Replicates x4.", + "unit": "odds-comparison" + }, + { + "title": "All examples use the deprecated `event=` param, which hard-sunsets 2026-07-01 (5 days out) — copy-pasted code will start failing", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/pkg/filters/parse.go:120 canonical=[\"event_id\"], aliases=[\"event\",\"events\",\"event_ids\"]; endpoints.go:73 applyAliasDeprecationHeaders(w,res,\"odds_comparison\"); filter_helpers.go:73 const filterAliasSunset=\"Wed, 01 Jul 2026 00:00:00 GMT\". Doc uses `event` at lines 27,41,45,52,73,289. LIVE ?event= -> Deprecation: true; Sunset: Wed, 01 Jul 2026 00:00:00 GMT; Warning: 299 ... use event_id.", + "note": "Confirmed code + live headers. Note: source comment (parse.go:78-80) implies `event` rides the alias slot for canonical-wins precedence but is a first-class alias; the Warning string explicitly calls it deprecated and gives the sunset. Whether it HARD-rejects post-sunset is not provable from code (no hard-fail branch found), but it definitively emits Sunset now, so docs steering users to the soon-sunset spelling is real. HIGH is appropriate. Replicates x4.", + "unit": "odds-comparison" + }, + { + "title": "selection.books documented as an object keyed by book id; reality is an array of book objects", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/endpoints.go:108-114 bookEntry{Sportsbook json:\"sportsbook\",OddsAmerican,OddsDecimal,LastSeenAt json:\"timestamp\"}; 173 Books []bookEntry (array), sorted desc at 187-189. Doc line 241 says books is 'object | ... keyed by book ID' and sample 126-147 shows books:{fanduel:{...}}. LIVE: books type=list, first={'sportsbook':'kalshi','odds_american':4900,'odds_decimal':50,'timestamp':...}.", + "note": "Confirmed code + live. The book id is a field inside each element, not the key. Replicates x4.", + "unit": "odds-comparison" + }, + { + "title": "hold is documented as a per-market object {best_available, by_book}; reality is per-entry book_holds array", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/endpoints.go:165-168 bookHoldEntry{Sportsbook,Hold}; 174 BookHolds []bookHoldEntry json:\"book_holds,omitempty\"; 197-233 computes per-book hold by pairing each book's price with that book's opposing-side price — no cross-book best_available, no by_book object, no `hold` key. Doc lines 112-120, 229-231, 258-270 document hold/hold.best_available/hold.by_book. LIVE entry omitted book_holds (queried selection had no opposing side), consistent with omitempty + the futures market having no opposite.", + "note": "Confirmed code; live entry's omission is consistent (single-side futures market). Replicates x4.", + "unit": "odds-comparison" + }, + { + "title": "Selection Object documents best_book/best_odds/worst_book/worst_odds/spread fields that do not exist", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/endpoints.go:169-175 comparisonEntry has only {market_type,selection,line,books,book_holds}; 187-189 only sorts books desc (no best/worst/spread emitted). Doc lists best_book/best_odds/worst_book/worst_odds/spread (Selection Object table 242-246, sample 148-152/180-184) plus a whole 'Understanding the spread Field' section 272-280. LIVE entry keys=['market_type','selection','line','books'] — none present.", + "note": "Confirmed code + live. Client could derive best/worst from books[0]/books[last] (sorted desc) but API does not emit these fields. Doc also documents selection_type (line 124/239) and event-level fields that likewise don't exist — same class. Replicates x4.", + "unit": "odds-comparison" + }, + { + "title": "Response envelope wholesale-wrong: shows {success, meta:{count,total,books_changed,pagination,filters}} but real is flat {data, removed, pagination, updated_at, meta:{server_time}}", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "DOC content/en/api-reference/odds-delta.mdx:111-153 shows success:true (L113), meta.count/total (L137-138), meta.pagination nested (L141-146), meta.filters (L147-150); schema L204-209 reinforces meta gets server_time+books_changed. CODE main.go:975-999 OddsResponse{Data,Removed,Pagination(top-level),UpdatedAt,Meta(*OddsResponseMeta)}; OddsResponseMeta main.go:1004-1006 = ServerTime ONLY; encode site endpoints.go:731-744. LIVE /odds/delta?sportsbook=pinnacle&sport=baseball -> top-level keys ['data','meta','pagination','removed','removed_truncated','updated_at']; meta={'server_time':...}; pagination top-level {limit,offset,count:14,total:14,has_more,next_offset}; success absent; filters absent. NOTE prior audit cited struct at endpoints.go:634 — actual struct is main.go:975. en defect replicates x4 (de/es/pt-BR mirrors carry it).", + "note": "Confirmed by code + live. locale_scope: en + de/es/pt-BR (x4). Prior audit's struct line (endpoints.go:634) was off; struct is main.go:975-999, encode is endpoints.go:731. Reality description otherwise exactly correct.", + "unit": "odds-delta" + }, + { + "title": "books_changed field does not exist — JS and Python example code throws on copy-paste", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "DOC JS odds-delta.mdx:71 `meta.books_changed.join(', ')`; Python :98 `result['meta']['books_changed']`; schema table :209 documents meta.books_changed; JSON sample :140 `\"books_changed\": [\"draftkings\"]`. CODE OddsResponseMeta main.go:1004-1006 has only ServerTime — no BooksChanged field anywhere (grep books_changed/BooksChanged across all .go = zero hits). LIVE meta={'server_time':...}; 'books_changed' in meta = False. JS .join() on undefined throws TypeError; Python ['books_changed'] raises KeyError on the second poll.", + "note": "Confirmed by code + live. Copy-pasted JS/Python clients crash. locale_scope x4.", + "unit": "odds-delta" + }, + { + "title": "Examples chain since from meta.server_time but read other nonexistent meta fields; pagination access is also wrong", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "DOC odds-delta.mdx:69-74 (JS) and :95-101 (Python) destructure meta then use meta.server_time (valid) alongside meta.books_changed (phantom); JSON sample :141-146 nests pagination under meta and puts count under meta (:137). CODE main.go:1005 ServerTime real; main.go:978 Pagination is top-level (not under meta); count lives in Pagination not meta. LIVE meta.server_time valid ('2026-06-27T02:02:12...'); pagination is top-level with count:14; meta has no count/pagination. So since-chaining works but the surrounding example mixes one real field with phantom meta.books_changed / meta.pagination / meta.count.", + "note": "Confirmed. server_time chaining itself is fine; the example's other meta accesses are wrong. HIGH appropriate (the broken line in the same examples is already its own CRITICAL #2). locale_scope x4.", + "unit": "odds-delta" + }, + { + "title": "401 error documents code `unauthorized`; real codes are `missing_api_key` / `invalid_api_key`", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "DOC odds-delta.mdx:189-198 shows code:\"unauthorized\", message 'Invalid or missing API key', docs https://docs.sharpapi.io/en/authentication. CODE auth.go:1163 emits errcodes.MissingAPIKey; auth.go:1206/1277/1366 emit errcodes.InvalidAPIKey; pkg/errcodes/errcodes.go:21 InvalidAPIKey=\"invalid_api_key\", :25 MissingAPIKey=\"missing_api_key\", :33 Unauthorized=\"unauthorized\" (reserved for admin/monitoring Bearer, never this endpoint). LIVE bad key -> {\"code\":\"invalid_api_key\",\"docs\":\"https://sharpapi.io/docs/authentication\"}; no key -> {\"code\":\"missing_api_key\", same docs URL}. Endpoint never returns 'unauthorized'.", + "note": "Confirmed by code + live. Secondary: doc's auth docs URL https://docs.sharpapi.io/en/authentication is also wrong — live returns https://sharpapi.io/docs/authentication (not severity-changing). locale_scope x4.", + "unit": "odds-delta" + }, + { + "title": "Response envelope wrong: documents {success, meta} wrapper; API returns flat {data, pagination, updated_at}", + "verdict": "confirmed", + "evidence": "sago-main/main.go:8033-8037 json.NewEncoder.Encode emits exactly {\"data\":page,\"pagination\":Pagination{...},\"updated_at\":updatedAt} — no \"success\", no \"meta\". transformArbitrage (transforms.go:1136-1162) returns only the per-item object; grep for '\"success\"' / '\"meta\"' tied to arbitrage in main.go+transforms.go = 0 hits. Empty-path encoder (main.go:7377-7387) is the same {data,pagination,updated_at} shape. Docs opportunities-arbitrage.mdx:97-176 document success+meta.source+meta.summary+meta.filters+meta.books_analyzed; JS example line 64 destructures `const {data, meta}`.", + "note": "Confirmed against origin/main code. Live 200 body unavailable (agent SHARPAPI_KEY returns 401 invalid_api_key on OVH :3003 right now), but code is unambiguous and matches the documented-as-flat envelope contract. The fictional meta.summary/filters/books_analyzed block and the success key are the load-bearing breakage; meta destructure in the JS example yields undefined. Locale_scope: replicates x4 (en/de/es/pt-BR mirror).", + "unit": "opp-arb" + }, + { + "title": "format=csv is a no-op: param + entire CSV Format section are fictional", + "verdict": "confirmed", + "evidence": "No text/csv, Content-Disposition, or attachment anywhere in sago-main/main.go or pkg/filters/ — every 'CSV' hit is comma-separated-value parsing (parseCSVFilter/splitCSV). opportunitiesHandler (main.go:7269-8038) never reads q.Get(\"format\") and hardcodes Content-Type: application/json (main.go:8031). Docs opportunities-arbitrage.mdx:29 (format json|csv, arbitrage_YYYY-MM-DD.csv) and :317-323 (CSV Format columns) describe a feature that does not exist. Live: GET ...?format=csv response header = Content-Type: application/json.", + "note": "Confirmed. Missing-feature/docs-only with graceful fallback (returns normal JSON, no error) and no security/data-integrity impact, so it borders MEDIUM, but HIGH is defensible because it can derail an integrator building a CSV-download flow that silently gets JSON. Kept HIGH. Locale_scope: replicates x4.", + "unit": "opp-arb" + }, + { + "title": "Leg field documented as implied_probability; API emits odds_probability", + "verdict": "confirmed", + "evidence": "sago-main/transforms.go:1069 emits \"odds_probability\": implProb on each arb leg; no \"implied_probability\" key is set on the leg map (1063-1076). Prior pass cited transforms.go:968 — real location is 1069 (line drift, same fact). Internal engine type pkg/arbengine/types.go:111 uses ImpliedProb json:\"implied_probability\" but the REST transform renames it to odds_probability for the API surface. Docs leg table opportunities-arbitrage.mdx:259 + JSON sample lines 128,141 use implied_probability.", + "note": "Confirmed against origin/main; line number corrected from 968 to 1069. Customer code reading leg.implied_probability gets undefined/KeyError. Live leg-key read unavailable (401), but code is definitive. Locale_scope: replicates x4.", + "unit": "opp-arb" + }, + { + "title": "Response envelope is wrong: docs show {success, meta, meta.summary, meta.filters}; API returns flat {data, pagination, updated_at}", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "sago-main/main.go:8033-8037 — opportunitiesHandler success path encodes map{\"data\":page, \"pagination\":Pagination{...}, \"updated_at\":updatedAt}; no \"success\", no \"meta\". grep '\"success\"' and '\"summary\"'/by_sportsbook/avg_ev across main.go+transforms.go = 0 hits (the by_sport at main.go:8227 is the middles /summary subroute, not the EV envelope). Docs claim it at opportunities-ev.mdx:119 (success:true), :200-220 (meta+summary+by_*), :354-363 (Meta Summary Fields table). Live confirmed envelope shape via 401 path but Pro-gated 200 body unobtainable with agent key (returned invalid_api_key).", + "note": "Confirmed at origin/main code level. The empty-result branch (main.go:7378-7386) emits the SAME {data,pagination,updated_at} shape, so even an empty 200 has no meta. Line numbers differ from the stale audit (it cited 6984-6988) but substance holds. locale_scope: en is canonical; de/es/pt-BR mirror, so replicates x4 if those translations copy the same sample.", + "unit": "opp-ev" + }, + { + "title": "JS/Python examples destructure meta/meta.count/meta.summary that don't exist — copy-pasted code throws/prints undefined", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "Docs JS sample opportunities-ev.mdx:89-91 — `const { data, meta } = await response.json(); console.log(\\`Found ${meta.count}...\\`); console.log(\\`Average EV: ${meta.summary.avg_ev}%\\`)`. Response has no meta key (sago-main/main.go:8033-8037), so meta===undefined → meta.count is undefined and meta.summary.avg_ev throws TypeError. Real count lives at pagination.count/pagination.total; avg_ev is not emitted anywhere.", + "note": "Directly follows from finding 1. Copy-pasted JS quickstart throws at runtime. locale_scope: replicates x4 across locale mirrors.", + "unit": "opp-ev" + }, + { + "title": "EV response field names are wrong: game_id/market/game/confidence_score should be event_id/market_type/event_name/confidence", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "transformEV output map sago-main/transforms.go:1290-1325 emits event_id (:1292, =nilIfEmpty getStr raw 'game_id'), event_name (:1293), market_type (:1298), confidence (:1323). No output keys game_id/game/market/confidence_score. (game_id at :1292/1283 is the RAW INPUT key read, not an emitted JSON key.) Docs use game_id (opportunities-ev.mdx:123,159,288), market (:133,169,340), game (:136,172), confidence_score (:143,179,310).", + "note": "Confirmed at origin/main. Field-naming drift is real and pervasive in the docs page (JSON samples + Response Fields table). locale_scope: replicates x4.", + "unit": "opp-ev" + }, + { + "title": "403 error example uses fictional code feature_required; real gate returns tier_restricted with tier/required_tier", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "Docs 403 example opportunities-ev.mdx:262-266 = {error:{code:\"feature_required\", message:\"The 'ev' feature is required...\", docs:\"https://docs.sharpapi.io/en/pricing\"}}. Real path: route gate for 'ev' derived from feature name (sago-main/routes.go:100 apiKeyRoute(...,\"ev\",...)) + handler self-gate (main.go:7296-7300) both call writeTierRestricted (tiers.go:472-485) → {error:{code:\"tier_restricted\", message:\"This endpoint requires Pro tier or higher\", docs:\"https://sharpapi.io/pricing\", tier, required_tier:\"pro\"}}. errcodes.TierRestricted=\"tier_restricted\" (pkg/errcodes/errcodes.go:31). grep feature_required across *.go = 0 hits.", + "note": "Confirmed. NB the stale audit cited `evGate := tierGateMiddleware(\"ev\")` at routes.go:49 — that explicit variable form no longer exists on origin/main (gates are now derived from each route's TierFeature via apiKeyRoute/gateForFeature, routes.go:50-100), but behavior is byte-identical. Docs also use the wrong docs URL (docs.sharpapi.io/en/pricing vs real sharpapi.io/pricing). locale_scope: replicates x4.", + "unit": "opp-ev" + }, + { + "title": "Response envelope is wholesale wrong: docs show {success, data, pagination, meta} but API returns {data, pagination, updated_at} with no success/meta", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "CODE (origin/main): main.go:8033-8037 success-path encoder = map{\"data\":page,\"pagination\":Pagination{...},\"updated_at\":updatedAt} — no success, no meta; empty-path encoder main.go:7377-7387 same shape. Pagination struct (used at 8035) = flat-paginated {Limit,Offset,Count,Total,HasMore,NextOffset,NextCursor,Warning}. low_hold route -> opportunitiesHandler (routes.go:104). DOCS: content/en/api-reference/opportunities-low-hold.mdx:101 \"success\": true; :165 \"meta\":{ :166 \"source\":\"cache\", :167-179 summary{count,avg_hold,min_hold,by_market,by_sport}, :180-188 filters{...}}. LIVE: /api/v1/opportunities/low_hold?limit=2 -> TOP KEYS [data, pagination, updated_at]; PAGINATION KEYS [count,has_more,limit,next_offset,offset,total]; HAS success: False; HAS meta: False.", + "note": "Confirmed against all three sources. Finding's cited line (main.go:6984-6988) is from the stale 261-behind checkout; real origin/main location is main.go:8033-8037 (success) and 7377-7387 (empty). Docs invent two top-level fields (success, meta incl. meta.summary aggregation + meta.filters) that don't exist — clients coding response.success / response.meta.summary.avg_hold would break. Wrong contract, not cosmetic. HIGH is correct (CRITICAL reserved for security/auth). locale_scope: replicates x4 — same block present in de/es/pt-BR mirrors of opportunities-low-hold.mdx.", + "unit": "opp-lowhold" + }, + { + "title": "Middles-summary documents a wholly fictional response body", + "verdict": "confirmed", + "corrected_severity": "CRITICAL", + "evidence": "main.go:8146-8232 (middlesSummaryImpl: bucket={count,sumQuality,sumEV}; finalize emits count/avg_quality_score/avg_expected_value; data has ONLY total/by_sport/by_league). live: /opportunities/middles/summary -> top keys ['data','updated_at']; data keys ['by_league','by_sport','total']; by_sport.baseball bucket keys = ['avg_expected_value','avg_quality_score','count']. Docs (opportunities-middles-summary.mdx JSON sample + Response Fields table) promise positive_ev_count, guaranteed_profit_count, player_prop_count, by_market, by_sportsbook, avg_middle_size, avg_probability, avg_ev, best_ev{ev,sport,market}, has_key_numbers_count — NONE exist in code or live.", + "note": "Confirmed against origin/main code AND live API. Line numbers shifted from the prior pass (their 7097-7186 -> actual 8146-8232) but the substance is exact. CRITICAL is correct: the entire documented response body (JSON sample, field table, and JS+Python examples reading data.positive_ev_count / data.best_ev.ev etc.) is fictional — every customer dashboard built on this page breaks. locale_scope: en canonical, replicates x4 (de/es/pt-BR mirror).", + "unit": "opp-middles" + }, + { + "title": "Both middles pages wrap response in {success, data, meta} — real envelope is {data, pagination, updated_at} / {data, updated_at}", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "live: /opportunities/middles?limit=2 -> top keys ['data','pagination','updated_at']; success absent, meta absent; pagination={count,has_more,limit,next_offset,offset,total}. live: /summary -> top keys ['data','updated_at']. main.go:8224-8231 (summary encodes {data,updated_at}). Docs: opportunities-middles.mdx line 91 \"success\": true, line 140 \"meta\": {...} (with nested pagination), line 49 const {data, meta}; opportunities-middles-summary.mdx line 41 const {data, meta}.", + "note": "Confirmed. List uses the flat-paginated envelope {data,pagination,updated_at}; summary/detail use flat {data,updated_at}. The wrapped {success,data,meta} envelope is reserved for account/keys + historical/* + splits/history only — middles is NOT in that group. JS examples destructure const {data, meta}; meta is undefined at runtime, and pagination (documented as nested under meta) is actually top-level. HIGH correct. locale_scope: x4.", + "unit": "opp-middles" + }, + { + "title": "Side objects documented with nested odds{} — API emits flat odds_american/odds_decimal/odds_probability/fair_probability", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "transforms.go:1526-1555 (side1Map/side2Map keys: odds_american(int), odds_decimal, odds_probability, fair_probability — flat, no nested odds{}). live: /opportunities/middles?limit=2 -> side1 keys = ['book','external_event_id','fair_probability','line','market_id','odds_age_seconds','odds_american','odds_decimal','odds_probability','selection','selection_id','stake_percent']; side1.odds nested object = false. Docs: opportunities-middles.mdx JSON samples lines 107/115/265 \"odds\":{american,decimal,probability,fair_probability}; Side Object table rows 241-244 list odds.american/odds.decimal/odds.probability/odds.fair_probability.", + "note": "Confirmed against code and live. Customer reading side1.odds.american gets undefined. HIGH correct. locale_scope: x4.", + "unit": "opp-middles" + }, + { + "title": "min_size query parameter is a complete no-op (silently ignored)", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "grep min_size across *.go (non-test) = ZERO hits (no parser). The only middles-specific numeric filter is main.go:7740-7752 (opType=='middles' applies ONLY res.Filters.MinProfit vs roi_percentage). pkg/filters/unknown.go:28 registers min_profit but not min_size, so min_size is silently dropped (not rejected). live: /opportunities/middles?limit=1 total=221; with &min_size=99 total still 221 (no change, no error). Docs: opportunities-middles.mdx line 27 documents min_size default 0.5; cURL/JS/Python examples lines 39/46/70 all pass min_size=1; Best Practices lines 386/401 instruct setting min_size>=1.", + "note": "Confirmed. min_size has no parser anywhere in non-test Go; passing it changes nothing and is not rejected as invalid_filter (registry has no entry to validate against). Docs repeatedly tell users to rely on it. HIGH correct. locale_scope: x4.", + "unit": "opp-middles" + }, + { + "title": "Documented splits sources (DraftKings/Circa) never appear in responses; real sources are 'consensus' and 'betmgm'", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "Code: /tmp/claude-0/-root-sharpapi-io/75e91e9e-0963-4724-9c4d-d1a1a8091b4f/scratchpad/sago-main/cache.go:1746 calls collapseSplitsToConsensus(games); splits_consensus.go:78-80 sets consensus.Sportsbook=\"consensus\" when all per-book rows for an event are byte-identical. Docs: content/en/api-reference/splits.mdx:9,30-31,102 ('from DraftKings and Circa Sports'; sportsbook is 'draftkings' or 'circa'). LIVE: /api/v1/splits?limit=200 -> {consensus:19, betmgm:16}, total 35; zero draftkings/circa rows. betmgm IS a real /splits source but is undocumented as such (docs only mention betmgm at splits.mdx:128-130 as a separate inline public_bet_pct field on /odds, not a /splits source).", + "note": "Confirmed against origin/main code + live. Substance exact: post-collapse no row is ever draftkings/circa; sources are consensus + betmgm. The prior audit's specific live counts (betmgm:24/total 43) are stale — live now shows betmgm:16/total 35 — but that is just live data drift, the claim holds. Replicates x4 locales (de/es/pt-BR mirror the en page, all carry the draftkings/circa claim). HIGH is correct: customer-facing field-value contract is wrong.", + "unit": "splits" + }, + { + "title": "Example queries use sportsbook=draftkings / sportsbook=circa, which return zero results on /splits", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "Docs: content/en/api-reference/splits.mdx:41 (param example 'draftkings,circa'), :143 (curl '.../api/v1/splits?sportsbook=draftkings&sport=basketball'). LIVE: /api/v1/splits?sportsbook=draftkings&limit=5 -> {\"data\":null,...,\"count\":0,\"total\":0}; sportsbook=circa -> identical empty. Copy-pasting the documented calls returns nothing.", + "note": "Confirmed. Important correction to the prior reasoning: the live row tag 'consensus' is NOT an accepted filter value either — /api/v1/splits?sportsbook=consensus returns an invalid_filter error ({\"code\":\"invalid_filter\",\"fields\":{\"sportsbook\":[\"consensus\"]}}), so consensus rows are only reachable via unfiltered queries. The only working sportsbook filter on /splits is 'betmgm'. The documented filter values are thus actively broken (silent empty array), supporting HIGH. /splits/history (book field) still serves draftkings/circa (splits.mdx:184,209,228) — that part of the docs is fine. Replicates x4 locales.", + "unit": "splits" + }, + { + "title": "sports.mdx Response shows a fabricated `meta`/`pagination` envelope; real envelope is {data, updated_at}", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/main.go:7154-7157 (sportsHandler json.NewEncoder().Encode(map{\"data\":result,\"updated_at\":getStoreTimeISO()}) — no meta/pagination/count/total/filters). Doc: content/en/api-reference/sports.mdx:128-140 shows \"meta\":{count:19,total:19,pagination:{limit,offset,has_more,next_offset},updated_at,filters:{}}; JS line 37 destructures `const { data, meta }`. LIVE: /api/v1/sports -> top-level keys ['data','updated_at'], no meta key.", + "note": "Confirmed against origin/main code AND live. /sports is a flat NON-paginated endpoint (no offset/limit logic at all in the handler), so the entire meta+pagination block is fabricated and the JS example's `meta` destructure yields undefined. HIGH is correct (breaks documented examples verbatim; not CRITICAL — read-only catalog endpoint, no auth/billing impact). locale_scope: en canonical; de/es/pt-BR mirrors replicate ×4 if they carry the same block.", + "unit": "sports-leagues" + }, + { + "title": "leagues.mdx Response shows a fabricated `meta`/`pagination` envelope; real envelope is {data, updated_at}", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/main.go:9013-9016 (leaguesHandler json.NewEncoder().Encode(map{\"data\":result,\"updated_at\":getStoreTimeISO()}) — no meta/pagination/filters). Doc: content/en/api-reference/leagues.mdx:101-115 and 236-247 both show \"meta\":{count,total,pagination:{...},updated_at,filters:{sport}}; JS line 45 destructures `const { data, meta }`. LIVE: /api/v1/leagues?sport=baseball -> top-level keys ['data','updated_at'], no meta key.", + "note": "Confirmed against origin/main code AND live. /leagues does not paginate (handler has no offset/limit handling) and does not echo a filters block. Both response examples and the JS destructure are wrong. HIGH correct (examples fail; read-only catalog, no security impact). locale_scope: en canonical; ×4 mirror replication likely.", + "unit": "sports-leagues" + }, + { + "title": "leagues.mdx uses `name`; real league field is `display_name` — JS/Python examples read a nonexistent field", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "sago-main/main.go:8922 (League struct: `DisplayName string `json:\"display_name\"``) — no `name` field on the league object. Doc: content/en/api-reference/leagues.mdx response uses \"name\":\"NBA\" (e.g. lines 79,87,...), schema table line 257 (`| name | string | Human-readable league name |`), JS line 48 `league.name`, Python line 64 `league['name']`. LIVE: /api/v1/leagues?sport=baseball -> {\"id\":\"mlb\",\"display_name\":\"MLB\",\"numerical_id\":354,\"sport\":\"baseball\",\"event_count\":563,\"live_count\":17} — uses display_name, NO name key.", + "note": "Confirmed; finding correctly scoped to LEAGUES only. NOTE the contrast: the SPORTS object genuinely IS `name` (main.go:7033 `Name string `json:\"name\"``; live /sports -> \"name\":\"Soccer\"), so sports.mdx's `name` is correct and must NOT be 'fixed' to display_name. Documented JS/Python examples read league.name -> undefined/KeyError at runtime. HIGH correct (breaks examples; read-only). locale_scope: en canonical; ×4 mirror replication likely.", + "unit": "sports-leagues" + }, + { + "title": "Concurrent stream limit table says 10; real default is 1 (newer-wins), no 429 table", + "verdict": "confirmed", + "corrected_severity": "HIGH", + "evidence": "DOC: content/en/api-reference/stream.mdx:543-546 table 'WebSocket Add-on ($99/mo) | 10', 'Enterprise | Custom'; L548-549 Callout 'Exceeding your stream limit returns a 429 error with code too_many_streams.' CODE (origin/main a046718): tiers.go:167-174 defaultMaxConcurrentStreams returns 1 for any paid tier, 0 for free (comment L164 'Policy (2026-04-27): every tier defaults to 1 with newer-wins displacement'); streaming.go:961-966 maxSSEStreams returns Unkey metaMax only when >0, else the tier default of 1; streamslots.go:120-144 Acquire returns nil ONLY when max<=0, otherwise newer-wins: 'kick the oldest until we're back at or under max' (L138-143 old.signalKick()); sse.go:411-433 the 429 too_many_streams body is written ONLY in the slot==nil branch (i.e. sseMax<=0 => free/unset), and the comment at sse.go:411-414 states 'With the default per-tier max of 1, a second connect from the same key kicks the older session (newer-wins). Acquire only returns nil when max<=0'. No default of 10 exists anywhere (grep: MaxStreams sourced solely from Unkey json:maxStreams at auth.go:892 / keys.go:423). The reconnect-storm hint (sse.go:267) explicitly describes 'each newer connect kicks the older one (newer-wins)'.", + "note": "Finding fully confirmed against origin/main; prior pass's line numbers (tiers.go:137-144, sse.go:330-348) were stale but the real locations are tiers.go:167-174, streaming.go:961-966, sse.go:411-433, streamslots.go:120-144 — same behavior. Severity kept at HIGH (prior pass also said HIGH): docs overstate the hard cap 10x (10 vs 1) AND mis-describe over-limit behavior — reality silently kicks the older stream (newer-wins) rather than rejecting the new one with 429; 429 too_many_streams only fires for free/unset (cap<=0). A multi-process consumer built per the docs would expect 10 streams and instead suffer silent disconnect loops. Not a security/data-integrity issue, so not CRITICAL. locale_scope: en is canonical; replicates x4 across de/es/pt-BR mirrors. Did not perform a live two-socket displacement test (requires concurrent connects); code evidence is conclusive and prior pass cross-checked live.", + "unit": "stream-sse" + }, + { + "title": "Response envelope is {data, pagination, updated_at}, not {data, meta}", + "verdict": "confirmed", + "evidence": "sago-main/endpoints.go:1148-1156 — json.NewEncoder().Encode(map{...}) emits keys data / pagination (Pagination struct: limit,offset,count,total,has_more,next_offset,warning) / updated_at; NO meta. live: /api/v1/teams?sport=baseball&limit=2 -> {\"data\":[...],\"pagination\":{\"limit\":2,\"offset\":0,\"count\":2,\"total\":329,\"has_more\":true,\"next_offset\":2},\"updated_at\":\"2026-06-27T02:03:04Z\"}. Docs (content/en/api-reference/teams.mdx:138-189) document a top-level meta object with count/sport_filter/league_filter/query/total_events/total_live/updated — none exist.", + "note": "Confirmed by both origin/main code and live. Prior auditor's cite endpoints.go:1050-1057 is from the stale checkout; real encoder is at 1148-1156, Pagination struct used at 1150-1153. Severity HIGH correct: JS `const {data, meta}` (mdx:56) and Python `result['meta']['count']` (mdx:77) break. locale_scope: en doc replicates ×4 (de/es/pt-BR mirror).", + "unit": "teams" + }, + { + "title": "Documented `q` alias search does not exist — q only substring-matches the team name", + "verdict": "confirmed", + "evidence": "sago-main/endpoints.go:1098 — `if qFilter != \"\" && !strings.Contains(strings.ToLower(ta.key.Name), qFilter) { continue }` — searches ONLY ta.key.Name; no alias set exists in teamAccum (endpoints.go:917-930) or TeamResult (1080-1093). live: /api/v1/teams?q=man%20city -> {\"data\":[],...\"total\":0...} (Manchester City NOT matched). Docs (teams.mdx:27-29 Callout, 197-207 use case) claim q matches name AND aliases.", + "note": "Confirmed code + live. Prior cite endpoints.go:1000 is stale; real q-filter is endpoints.go:1098. Severity HIGH correct.", + "unit": "teams" + }, + { + "title": "Response has no `aliases` field — entire alias-mapping use case is fictional", + "verdict": "confirmed", + "evidence": "sago-main/endpoints.go:1080-1093 — TeamResult struct fields: Name,NumericalID,Abbreviation,Logo,City,Mascot,Conference,Division,Sport,League,EventCount — NO Aliases. grep -i alias endpoints.go shows only applyAliasDeprecationHeaders (param-name deprecation) + a collapse comment, never a response field. live: baseball rows contain no `aliases` key. Docs schema row aliases (teams.mdx:165) + JS team.aliases.length/join (60-62) + Python team['aliases'] (79-80) + Success-example aliases arrays (109-111,129-133).", + "note": "Confirmed code + live. Prior cite endpoints.go:982-995 is stale; real struct at 1080-1093. JS/Python samples throw (read .length / iterate undefined). Severity HIGH correct. locale_scope: ×4. Aside (not in finding scope): live logo host is cdn.sharpapi.io, doc example shows cdn.opticodds.com — separate doc drift.", + "unit": "teams" + }, + { + "title": "Close code 4029 does not exist (server never sends it); over-limit is HTTP 429 at upgrade or 4001 displacement", + "verdict": "confirmed", + "evidence": "grep '4029' across *.go in sago-main = 0 hits. websocket.go:3059-3066 returns HTTP 429 + errcodes.TooManyStreams at upgrade when slot==nil (only when effectiveMaxStreams<=0, i.e. free/unset). websocket.go:3434 sends FormatCloseMessage(4001,\"displaced by newer session\") for newer-wins. Only custom WS close codes defined: wsCloseAuthFailed=4001 (websocket.go:156), wsCloseEntitlementsChanged=4003 (websocket.go:164). Doc claims 4029 at websocket.mdx:614 (Close Codes table), :906 (warning), :916 (Best Practice #6).", + "note": "Confirmed. Prior audit cited stale line numbers (upgrade-429 was 2566-2581, displacement 2864-2868 in the behind-checkout) — real origin/main locations are 3059-3066 and 3434. Substance exact. Minor: finding's evidence said BP #11 (line 921) claims 4029, but :921 actually describes the correct 1-stream/4001 behavior; the real second 4029 mention is BP #6 (:916). Core claim (4029 documented but nonexistent in code) is fully correct. locale_scope: api-reference/websocket.mdx mirrored in de/es/pt-BR -> replicates x4.", + "unit": "websocket" + }, + { + "title": "Concurrent stream limit is 1 (newer-wins), not 10", + "verdict": "confirmed", + "evidence": "tiers.go:167-174 defaultMaxConcurrentStreams returns 0 for free, 1 for every other tier. tiers.go:164-166 comment: 'every tier defaults to 1 with newer-wins displacement.' websocket.go:3050-3052 acquires via effectiveMaxStreams(keyData); newer-wins kick at websocket.go:3425-3438 (4001). Doc says '10' at websocket.mdx:902 (Concurrent Stream Limits table); :921 (BP #11) correctly contradicts with 'Each key allows 1 concurrent stream by default'.", + "note": "Confirmed. No '10' default exists in code — only Unkey maxStreams metadata override raises it (effectiveMaxStreams, tiers.go:181-186). Doc table at :902 is wrong; its own BP #11 contradicts it. locale_scope x4.", + "unit": "websocket" + }, + { + "title": "opportunities_snapshot arrays are nested under data, not at top level — all 4 code examples access them wrong", + "verdict": "confirmed", + "evidence": "websocket.go:5398-5405 sendOpportunityRowsCapped emits {\"type\":\"opportunities_snapshot\",\"data\":{opType: transformed[lo:hi]}} — array nested under data, no seq/timestamp. Doc example websocket.mdx:192-222 shows top-level 'ev' + 'seq' + 'timestamp'; :225 says 'top-level key matches the channel type'. Client snippets read wrong path: :704 msg.ev, :763 msg.low_hold, :809 'middles' in msg, :857 destructures top-level {ev,arbitrage,middles,low_hold}.", + "note": "Confirmed. Prior audit's second location (4605-4612 subscribe-snapshot path) maps to the same sendOpportunityRowsCapped helper used by both snapshot paths, so single canonical emit site at 5398-5405. No top-level ev/low_hold/middles, no seq, no timestamp on this message. locale_scope x4.", + "unit": "websocket" + }, + { + "title": "initial message uses book, not source; has no seq/count/timestamp; data is an array not an object", + "verdict": "confirmed", + "evidence": "websocket.go:5540-5546 emits {\"type\":\"initial\",\"book\":book,\"data\":bookOdds[lo:hi]} — field is 'book' (singular), data is an ARRAY slice, no seq/source/count/timestamp. Doc example websocket.mdx:235-243 shows 'source':'draftkings','seq':4,'count':1500,'timestamp'. Browser snippet :707 does Object.keys(msg.data) treating data as a book-keyed object.", + "note": "Confirmed. Object.keys() on an array yields indices, not book names — :707 is broken. 'source' (:239,279,296,311 across examples) vs real 'book'. locale_scope x4.", + "unit": "websocket" + }, + { + "title": "subscribed message shape is wrong: real msg is per-channel {channel, filters:{...}}, not a single flat multi-channel object", + "verdict": "confirmed", + "evidence": "websocket.go:4393-4404 loops per channel, sending ack {\"type\":\"subscribed\",\"channel\":ch,\"filters\":c.filtersSnapshot()} + optional filter_warnings. filtersSnapshot (websocket.go:6127-6163) uses SINGULAR nested keys: sport/league/sportsbook/market/event plus live/min_ev/min_profit/min_odds/max_odds. Doc example websocket.mdx:172-185 shows ONE message with 'seq','channels':[...], flat plural top-level arrays (sports/sportsbooks/leagues/markets/eventIds),'timestamp'.", + "note": "Confirmed. Server sends one ack PER channel (channel singular string), filters nested with singular keys; no seq, no timestamp, no flat plural arrays, no channels[] array. locale_scope x4.", + "unit": "websocket" + }, + { + "title": "*:expired payloads put expired at top level, not nested under data — Node/Python examples throw", + "verdict": "confirmed", + "evidence": "websocket.go:1528-1533 emits {\"type\":eventPrefix+\":expired\",\"seq\":seq,\"global_seq\":,\"expired\":expiredIDs} — 'expired' at TOP LEVEL, no 'data' wrapper. Doc example websocket.mdx:360-369 (ev:expired) shows {type,seq,data:{expired:[...]},timestamp}. Client snippets read wrong path: :773 msg.data.expired.length (Node), :809-817 region msg['data']['expired'] (Python).", + "note": "Confirmed. Real shape adds global_seq (string) not shown in docs, and has no data wrapper. msg.data.expired -> TypeError/KeyError on real frames. ev/arb/middles/low_hold all share this emit site (eventPrefix). locale_scope x4.", + "unit": "websocket" + } + ], + "stats": { + "gap_units": 8, + "gap_findings": 71, + "gap_critical": 7, + "gap_high": 22, + "gap_medium": 25, + "gap_low": 17, + "verified": 79, + "confirmed": 78, + "refuted": 0, + "adjusted": 1 + } +}