Re-Enter on initials shows BAD JSON error + player stays — server submit-handler contract drift probe #86

Closed
opened 2026-06-22 13:10:09 +02:00 by bosun · 2 comments
Owner

Behavior (operator-observed 2026-06-22 post-round-11 deploy)

After entering initials at highscore entry + pressing Enter:

  • Each Enter seems to commit the initials, but the player stays in the same screen
  • A "BAD JSON" error appears below the initials
  • The control hint flashes for a fraction of a second on Re-Enter
  • Esc allows escape to landing screen (confirms #79 keyboard-exit working)

NOT a #80 regression (Shipwright code-reading hypothesis)

PR #84's #80 fix (synchronous submitting-phase inert-on-commit) is functioning as designed. The visual symptom is identical to the original #80 bug but the root cause is upstream.

Shipwright's reasoning (from code-reading of merged main, not yet probed live):

  • main.ts submitLeaderboard (lines 369-389): on !res.ok surfaces SERVER's error-body TEXT VERBATIM as banner (line 377), then reverts initialsPhase→'entering' for retry (line 378)
  • The "BAD JSON" string is almost certainly the server's own error body, not a client res.json() parse-throw
  • If it were a client parse-throw, the banner would read "network error — score not saved" (catch path, line 387)
  • So: server's /leaderboard/submit is rejecting the client request as decode-error each time → submit→server-error→revert→retry loop, visually identical to the original #80 bug but DIFFERENT root cause

Hypothesized root cause: contract drift between client + server

Client (main.ts:374) POSTs {initials, score, lines, durationMs} as JSON.

If the server's submit-handler decode-schema has drifted from this contract (e.g. DisallowUnknownFields, field rename, strict decode), a Go strict-decoder rejects valid-looking JSON as a decode error → "bad JSON" surfaces verbatim to the banner.

Suspicious newer fields per Shipwright: lines, durationMs (may be additions post-server-schema).

Sharpened probe (3-step)

(a) Live curl /leaderboard/submit with the real client payload {initials, score, lines, durationMs} against https://cellblock.frankenbit.de → capture status + body. Ground truth.

(b) Engineer: diff server submit-handler decode-schema in cellblock/server/leaderboard.go vs the client's 4-field payload. Identify the rejected field(s) or strict-decode flag.

(c) Shipwright: close harness gap with an error-resolution-path row (currently toSubmitting/holdSubmit HANGs the endpoint; never tests server-resolves-with-error path). Substrate-claim-vs-empirical-reality gap empirically located by this issue.

Lane + size

Engineer lane primary (server schema diff + likely fix). Shipwright lane secondary (harness gap close). Sizes unclear pending probe.

Acceptance criteria

  1. Probe identifies the exact server-side rejection mechanism (decode failure point, error message origin)
  2. Server-side fix lands so client's {initials, score, lines, durationMs} payload is accepted (or client adjusts to server schema, whichever side is wrong per substrate-truth)
  3. Re-Enter post-commit shows no "BAD JSON" error (because submit succeeds first time)
  4. Harness #81 extended with error-resolution-path row (server returns error → revert-to-entering → Re-Enter re-submits → next outcome). Mutation-proven.
  5. No regression on first-submit success path

Cross-refs

  • cellblock#80 (the original Re-Enter behavior, visually similar but different root cause)
  • PR #84 (#80 fix, functioning as designed)
  • Shipwright's hypothesis 3416 + 5095 (code-reading pre-positioning, not yet probed)
  • Discipline-instance: visual-equivalence between substrate-states does NOT imply substrate-state identity

Anchor

2026-06-22 ~13:00 operator playtest on round-11 deployed substrate. Discovered + diagnosed by Shipwright via code-reading the merged substrate without live-probing (filed-rootcause-is-hypothesis correctly applied — confirm via probe before fix-shape).

## Behavior (operator-observed 2026-06-22 post-round-11 deploy) After entering initials at highscore entry + pressing Enter: - Each Enter seems to commit the initials, but the player stays in the same screen - A "BAD JSON" error appears below the initials - The control hint flashes for a fraction of a second on Re-Enter - Esc allows escape to landing screen (confirms #79 keyboard-exit working) ## NOT a #80 regression (Shipwright code-reading hypothesis) PR #84's #80 fix (synchronous submitting-phase inert-on-commit) is functioning as designed. The visual symptom is identical to the original #80 bug but the root cause is upstream. **Shipwright's reasoning** (from code-reading of merged main, not yet probed live): - main.ts submitLeaderboard (lines 369-389): on `!res.ok` surfaces SERVER's error-body TEXT VERBATIM as banner (line 377), then reverts initialsPhase→'entering' for retry (line 378) - The "BAD JSON" string is almost certainly the server's own error body, not a client res.json() parse-throw - If it were a client parse-throw, the banner would read "network error — score not saved" (catch path, line 387) - So: server's `/leaderboard/submit` is rejecting the client request as decode-error each time → submit→server-error→revert→retry loop, visually identical to the original #80 bug but DIFFERENT root cause ## Hypothesized root cause: contract drift between client + server Client (main.ts:374) POSTs `{initials, score, lines, durationMs}` as JSON. If the server's submit-handler decode-schema has drifted from this contract (e.g. DisallowUnknownFields, field rename, strict decode), a Go strict-decoder rejects valid-looking JSON as a decode error → "bad JSON" surfaces verbatim to the banner. Suspicious newer fields per Shipwright: `lines`, `durationMs` (may be additions post-server-schema). ## Sharpened probe (3-step) (a) **Live curl** `/leaderboard/submit` with the real client payload `{initials, score, lines, durationMs}` against https://cellblock.frankenbit.de → capture status + body. **Ground truth.** (b) **Engineer**: diff server submit-handler decode-schema in cellblock/server/leaderboard.go vs the client's 4-field payload. Identify the rejected field(s) or strict-decode flag. (c) **Shipwright**: close harness gap with an error-resolution-path row (currently `toSubmitting/holdSubmit` HANGs the endpoint; never tests server-resolves-with-error path). Substrate-claim-vs-empirical-reality gap empirically located by this issue. ## Lane + size Engineer lane primary (server schema diff + likely fix). Shipwright lane secondary (harness gap close). Sizes unclear pending probe. ## Acceptance criteria 1. **Probe** identifies the exact server-side rejection mechanism (decode failure point, error message origin) 2. Server-side fix lands so client's `{initials, score, lines, durationMs}` payload is accepted (or client adjusts to server schema, whichever side is wrong per substrate-truth) 3. Re-Enter post-commit shows no "BAD JSON" error (because submit succeeds first time) 4. Harness #81 extended with error-resolution-path row (server returns error → revert-to-entering → Re-Enter re-submits → next outcome). Mutation-proven. 5. No regression on first-submit success path ## Cross-refs - cellblock#80 (the original Re-Enter behavior, visually similar but different root cause) - PR #84 (#80 fix, functioning as designed) - Shipwright's hypothesis 3416 + 5095 (code-reading pre-positioning, not yet probed) - Discipline-instance: visual-equivalence between substrate-states does NOT imply substrate-state identity ## Anchor 2026-06-22 ~13:00 operator playtest on round-11 deployed substrate. Discovered + diagnosed by Shipwright via code-reading the merged substrate without live-probing (filed-rootcause-is-hypothesis correctly applied — confirm via probe before fix-shape).
Owner

Probe result: root cause CONFIRMED — float durationMs vs server int64 decode (Shipwright's hypothesis verified, exact field pinned)

Ground truth — live curl against the round-11 deployed cellblock.frankenbit.de (pollution-free: both used score:-1 so neither placed on the board):

payload result
{…,"durationMs":1000} (int) 400 "score must be non-negative" — decode passed, fell through to the score check
{…,"durationMs":1000.5} (float) 400 "bad json" — decode failed on the float

Go's json.Decode of a JSON float into the handler's DurationMs int64 (server/leaderboard.go) errors → the handler returns its "bad json" body. The client surfaces that verbatim as the banner (main.ts:377) then reverts initialsPhase→'entering' (:378) — visually identical to #80 but a different root cause. NOT a #80 regression (PR #84 functioning as designed). Exact rejected field: durationMs.

Client layer (source proof): durationMsstate.soloElapsedMs (main.ts:413) = t - soloStartMs (main.ts:926), where t is the requestAnimationFrame callback timestamp — loop(t: number) at main.ts:850, driven by requestAnimationFrame(loop) (:906/:997). That's a DOMHighResTimeStamp, i.e. a sub-millisecond float. So durationMs is ~always fractional (e.g. 60123.456) → every solo submit fails the decode, matching the operator's "each Enter shows BAD JSON" (consistent, not intermittent).

Why surfaced post-round-11: the drift is latent — the server's int64 (#28) and the client's float elapsed only collide once durationMs rides the submit payload. Not load-bearing for the fix.

Fix-shape recommendation (AC#2 — "whichever side is wrong per substrate-truth")

durationMs is a duration in milliseconds — conventionally an integer; sub-ms precision is spurious (the client already Math.floors it to whole seconds for display, render.ts:550). So the client is the drift:

  • PRIMARY — client lane (Shipwright): round at the stamp — state.soloElapsedMs = Math.round(t - soloStartMs) (main.ts:926). One line, loses nothing (display already floors), fixes at the source of the spurious precision.
  • OPTIONAL — server lane (Engineer, defense-in-depth): make the decode tolerant of a float durationMs (decode via json.Number/float64, truncate to int64). Postel-liberal — robust to any future client float. Not required if the client rounds; flag if belt-and-suspenders against recurrence is wanted.
  • When server-primary would be right instead: multiple uncontrolled clients, or a deliberately-lenient contract for fast jam iteration. Here — single client + value already floored-for-display — client-round is the clean minimal fix.

AC#1 (exact rejection mechanism) ✓. No code changed (probe-only per the probe-first cycle). Fix dispatched separately.

## Probe result: root cause CONFIRMED — float `durationMs` vs server `int64` decode (Shipwright's hypothesis verified, exact field pinned) **Ground truth — live curl against the round-11 deployed `cellblock.frankenbit.de` (pollution-free: both used `score:-1` so neither placed on the board):** | payload | result | |---|---| | `{…,"durationMs":1000}` (int) | `400 "score must be non-negative"` — decode **passed**, fell through to the score check | | `{…,"durationMs":1000.5}` (float) | `400 "bad json"` — decode **failed on the float** | Go's `json.Decode` of a JSON float into the handler's `DurationMs int64` (`server/leaderboard.go`) errors → the handler returns its `"bad json"` body. The client surfaces that **verbatim** as the banner (`main.ts:377`) then reverts `initialsPhase→'entering'` (`:378`) — visually identical to #80 but a different root cause. **NOT a #80 regression** (PR #84 functioning as designed). Exact rejected field: **`durationMs`**. **Client layer (source proof):** `durationMs` ← `state.soloElapsedMs` (`main.ts:413`) = `t - soloStartMs` (`main.ts:926`), where `t` is the `requestAnimationFrame` callback timestamp — `loop(t: number)` at `main.ts:850`, driven by `requestAnimationFrame(loop)` (`:906/:997`). That's a `DOMHighResTimeStamp`, i.e. a **sub-millisecond float**. So `durationMs` is ~always fractional (e.g. `60123.456`) → **every** solo submit fails the decode, matching the operator's "each Enter shows BAD JSON" (consistent, not intermittent). **Why surfaced post-round-11:** the drift is latent — the server's `int64` (#28) and the client's float elapsed only collide once `durationMs` rides the submit payload. Not load-bearing for the fix. ### Fix-shape recommendation (AC#2 — "whichever side is wrong per substrate-truth") `durationMs` is a duration in **milliseconds** — conventionally an integer; sub-ms precision is spurious (the client already `Math.floor`s it to whole seconds for display, `render.ts:550`). So the **client is the drift**: - **PRIMARY — client lane (Shipwright):** round at the stamp — `state.soloElapsedMs = Math.round(t - soloStartMs)` (`main.ts:926`). One line, loses nothing (display already floors), fixes at the source of the spurious precision. - **OPTIONAL — server lane (Engineer, defense-in-depth):** make the decode tolerant of a float `durationMs` (decode via `json.Number`/`float64`, truncate to `int64`). Postel-liberal — robust to any future client float. Not required if the client rounds; flag if belt-and-suspenders against recurrence is wanted. - **When server-primary would be right instead:** multiple uncontrolled clients, or a deliberately-lenient contract for fast jam iteration. Here — single client + value already floored-for-display — client-round is the clean minimal fix. **AC#1 (exact rejection mechanism) ✓.** No code changed (probe-only per the probe-first cycle). Fix dispatched separately.
bosun closed this issue 2026-06-22 13:30:47 +02:00
Author
Owner

AC-tick pass — closed by PR #89 (squashed):

  1. Probe identifies exact server-side rejection mechanism ✓ — Engineer 3-axis substrate-grounded probe: live curl int (decode PASSED) vs float (decode FAILED), client source-trace to RAF DOMHighResTimeStamp = sub-ms float, Go json.Decode int64 strict-decode rejects → "bad json" surfaced verbatim. Full writeup at #86 (probe comment).
  2. Server-side fix OR client-side fix lands per substrate-truth ✓ — client-side fix selected (Math.round at main.ts:926, single write-point at only float source in payload). Client originates the drift (RAF timestamp convention); server strict-decode is correct as discipline. Server-tolerance leg DECLINED per substrate-strictness-catches-drift-loud discipline (same family as #80 content-dedup decline).
  3. Re-Enter post-commit shows no "BAD JSON" error — operator-device-gated post round-12 redeploy. Code axis + error-path guard verified done; real-flow confirmation requires live submit on production.
  4. Harness #81 extended with error-resolution-path row ✓ — mutation-proven (revert removed → reds, restored → green). Closes the coverage gap empirically located by this issue (toSubmitting/holdSubmit HANG route asserted in-flight path, not error-RESOLVE path).
  5. No regression on first-submit success path ✓ — harness 15/15 green 3× cold; preserved by the bounded Math.round delta.

Follow-up needed: round-12 redeploy + operator solo submit to confirm scores save end-to-end (n=1 of operator-device-gate variant family for this fix).

**AC-tick pass** — closed by PR #89 (squashed): 1. **Probe identifies exact server-side rejection mechanism** ✓ — Engineer 3-axis substrate-grounded probe: live curl int (decode PASSED) vs float (decode FAILED), client source-trace to RAF DOMHighResTimeStamp = sub-ms float, Go json.Decode int64 strict-decode rejects → "bad json" surfaced verbatim. Full writeup at #86 (probe comment). 2. **Server-side fix OR client-side fix lands per substrate-truth** ✓ — **client-side fix selected** (Math.round at main.ts:926, single write-point at only float source in payload). Client originates the drift (RAF timestamp convention); server strict-decode is correct as discipline. Server-tolerance leg DECLINED per substrate-strictness-catches-drift-loud discipline (same family as #80 content-dedup decline). 3. **Re-Enter post-commit shows no "BAD JSON" error** — operator-device-gated post round-12 redeploy. Code axis + error-path guard verified done; real-flow confirmation requires live submit on production. 4. **Harness #81 extended with error-resolution-path row** ✓ — mutation-proven (revert removed → reds, restored → green). Closes the coverage gap empirically located by this issue (toSubmitting/holdSubmit HANG route asserted in-flight path, not error-RESOLVE path). 5. **No regression on first-submit success path** ✓ — harness 15/15 green 3× cold; preserved by the bounded Math.round delta. Follow-up needed: round-12 redeploy + operator solo submit to confirm scores save end-to-end (n=1 of operator-device-gate variant family for this fix).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock#86
No description provided.