Files
vyndr/specs/wave3-compare-and-resolution-tail.md

6.1 KiB

SPEC — WAVE 3: /compare BUILT · resolution tail SCOPED (2026-07-31)

No grade, ledger, or scoring change (diff empty across intelligence/, ledgerService, outcomeService). Push scoring untouched.

REVIEW ZERO 0.3/0.4 — THE RESOLUTION TAIL: IT DOES NOT FIRE

The resolver IS POST /api/grading/resolve (src/routes/grading.js:208, documented "called by the ESPN poller at FINAL", internal-key + loopback gated). Its fanout at :356-371:

tail output state evidence
share-card generation SPEC'D-NOT-BUILT (as a pipeline step) grep shareCard src/routes/grading.js = 0 — it is not in the fanout at all. services/shareCards/renderer.js exists with zero callers → the component is BUILT-NOT-FIRING, but no step would ever invoke it.
push notifications BUILT-NOT-FIRING webPush.sendPushToSport(...) is in the fanout but gated on webPush.configured() (VAPID keys). push_subscriptions = 0 rows, user_notifications = 0 rows — nothing has ever been subscribed or delivered.
Telegram result posts BUILT-NOT-FIRING telegram.postToTelegram(...) in the fanout, gated on configured() = BOT_TOKEN && CHANNEL_ID.
Discord result posts BUILT-NOT-FIRING discord.postToDiscord('results', ...), gated on webhookFor('results').
recap (all-Final trigger) SPEC'D-NOT-BUILT No recap file exists anywhere in src/, and grep recap src/routes/grading.js = 0.

🔴 AND THE WHOLE TAIL IS UNREACHABLE: nothing calls /api/grading/resolve. There is no ESPN poller in the repo (grep grading/resolve outside the route itself = 0 hits). The live settlement path is the scheduler's settleAllOutcomes + settleAllLedgers, which fans out to opsNotify only (ops alerts to ntfy) — no user-facing output of any kind. So even the BUILT-NOT-FIRING channels have no trigger.

Therefore ShareCard, /notifications, result posts and recap are ALL scoped, none shipped — per the order's own rule: nothing ships as UI over a pipeline step that does not fire.

PHASE 1 — /compare BUILT

0.1 confirmed: the page was the honest in-development state (fabrication retired earlier). 0.2 semantics — a meaningful compare is a SAME-MARKET head-to-head: two players, every row a measure both sides are scored on, aligned so the numbers are actually comparable. Deliberately NOT "two arbitrary graded props side by side", which is two disconnected cards wearing a compare label.

Built on the live feed /api/stats/player/:name?sport= (the same aggregate the player profile renders — real: Aaron Judge → found:true, team, BOMBER archetype, season rows [{k,v}]). alignRows unions both sides' stat keys and renders each side's value or .

Honest-absent, three ways: a side that does not resolve renders NO DATA and the other side still shows · a measure only one side has renders , never 0 · if neither resolves the page refuses to compare ("we will not invent a comparison"). There is NO VERDICT — it shows the measures and explicitly says the reader draws the call.

PHASES 2/3 — SCOPED, NOT SHIPPED

ShareCard — needs a generation step inside the resolve fanout (render → store → serve URL), and that fanout needs a trigger. Not shipped: a share card with no generator is a dead shell, and per the order a public marketing artifact must carry Truth-Law-honest numbers, which requires a real resolved result to render from.

/notifications — the store EXISTS (push_subscriptions, user_notifications, both empty) and webPush exists, but there is no trigger and no consent/subscribe surface. Shipping a settings screen now would silently drop every notification — explicitly forbidden. Scope: (1) a caller for /api/grading/resolve (or move the fanout into the scheduler's settle pass), (2) VAPID config, (3) a consented subscribe UI, (4) then the settings screen. Types stay result/status-driven ("your graded pick resolved"), never promotional — the anti-capper line holds.

Result posts / recap — same gate. Recap has no code at all; it is a user-facing + SEO artifact, so it ships only when it can render real slate results with honest-absent thin data.

VERIFY

316 suites / 3,930 tests green (10 new), web build exit 0. Two pre-existing tests (vyndrPhaseE, vyndrParityQA) asserted the in-dev placeholder; both superseded, not deleted — they now assert the stronger properties against the real page (live fetch, no sample players, NO VERDICT, NO DATA, "not a zero").

Chrome manifest additions: /compare logged-out — enter two real players, confirm aligned rows and that a misspelled name reads NO DATA rather than a fabricated card.

TAGS

VERIFIED: all five tail states; /compare built on the live feed. CANNOT DETERMINE: whether VAPID / Telegram / Discord env is configured in prod (not visible externally) — moot while no trigger exists. BLOCKED: ShareCard, /notifications, result posts, recap — all on a resolution tail that never fires.

POST-DEPLOY FINGERPRINT (commit bf7c0a3)

Deploy boundary captured: attempts 1-4 still served IN DEVELOPMENT, attempt 5 flipped — the real page is live (Enter two players, nothing here is modelled), placeholder gone.

Live head-to-head, through the deployed proxy (Aaron Judge vs Shohei Ohtani, MLB):

.248   AVG   .289
  17    HR    23
  38   RBI    65
.908   OPS   .931
  59    GP   103        → 5 of 5 shared measures, both found:true

Honest-absent verified live: a bogus name returns found:false → renders NO DATA, no fabricated card.

⚠️ Verification-harness error worth recording: my first check reported found=false for both real players. That was my script, not the service — its except branch returned {found:False} and swallowed a urllib-side failure. curl through both the proxy and Express returns found:true with 5 season rows, 3/3 repeats. Lesson: a bare except in a verification harness fabricates a negative result, which is the same class of bug as Number(null) === 0 — the harness must fail loudly, not return a plausible-looking false.