Part of the ADR-0004 vanilla-shell investment track (see docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.
Goal
Give the five persisted-domain reads in src/state.ts that currently trust localStorage verbatim a fail-closed decoder each, following the pattern decodeStoredSavedQueries already establishes — drop malformed data to a safe default, never throw, never let a corrupt or hand-edited value reach the UI with the wrong shape.
Context
src/state.ts:221–227 decodes savedQueries through a real decoder:
// decodeStoredSavedQueries fails closed: `ok: false` carries diagnostics and
// ...
const decodeStoredSavedQueries = decodeStoredSavedQueriesUntyped as (value: unknown) =>
Five other persisted reads in the same file have no equivalent and say so explicitly in their own comments — each is a raw as cast straight from read.loadJSON:
| Field |
Site |
Comment |
varValues |
src/state.ts:696 |
"The as trusts the localStorage shape verbatim — no decoder exists today (unlike savedQueries)." |
filterActive |
src/state.ts:706 |
"The as trusts the localStorage shape verbatim — no decoder exists today." |
varRecent |
src/state.ts:717 |
"The as trusts the localStorage shape verbatim — no decoder exists today." |
varRecentDisabled |
src/state.ts:722 |
"The as trusts the localStorage shape verbatim — no decoder exists today." |
history |
src/state.ts:738 |
"The as trusts the localStorage shape verbatim — no decoder exists today." |
Corrupt, truncated, or hand-edited localStorage for any of these five keys decodes to a wrongly-typed object/array that reaches the Workbench UI as-is (variable strip, optional-block filters, MRU dropdowns, executed-query history) — it fails open, not closed. This is the same defect family as the already-closed #570 (NaN-unsafe clamp on the four numeric geometry preferences editorPct/sideSplitPct/cellDrawerPx/docPanePx), just on the object/array-shaped side of persisted state rather than the numeric side; #570's decoders never touched these five keys because they go through read.loadJSON + as, not clamp.
#570's current state: CLOSED (reason: Not Planned, closed 2026-08-03, no linked replacement or comment). This issue does not reopen #570 — it is independent, narrower-scoped work addressing the object/array-shaped reads #570 never covered. If #570's numeric-clamp defect is later judged worth resuming, it should be filed/reopened separately; this issue does not carry that scope.
Deliverables
For each of the five fields, add a small fail-closed decoder module (or function, matching the granularity decodeStoredSavedQueries uses) that:
- validates the persisted shape (e.g.
varValues/filterActive are plain string/boolean-keyed records; varRecent matches RecentMap's versioned shape; varRecentDisabled is a boolean; history is an array of well-formed HistoryEntry objects);
- drops malformed top-level values or malformed individual entries rather than throwing;
- falls back to the field's existing documented default (
{}, emptyRecentMap(), false, []) when the whole value is unusable;
- never surfaces a wrongly-typed value to the UI.
Replace each as cast at the five sites above with a call to its decoder.
Tests
- One test per field with a non-conforming stored value (wrong top-level type, wrong entry shape, truncated JSON already handled by
loadJSON's own parse-failure path, and at least one "individual entry malformed, rest kept" case where the field is a collection) asserting the decoder falls back to the documented default rather than propagating the bad shape.
- Existing "reads + clamps persisted prefs" coverage in
tests/unit/state.test.ts continues to pass unmodified in intent for well-formed input.
npm test (coverage gate) and tsc --noEmit pass.
Acceptance criteria
varValues, filterActive, varRecent, varRecentDisabled, and history are each read through a fail-closed decoder, not a raw as cast.
- Each decoder falls back to the field's existing documented default on malformed input and never throws.
- Malformed individual entries in a collection-shaped field are dropped without discarding the whole collection where that's a meaningful distinction (e.g.
history array, varRecent map).
npm test (coverage gate) and tsc --noEmit pass.
Non-goals
- Touching the four numeric geometry preferences (
editorPct, sideSplitPct, cellDrawerPx, docPanePx) or clamp's NaN-safety — that was #570's scope, now closed as Not Planned; this issue does not resume it.
- Any behavior change to well-formed persisted data — this is a decode-time safety net, not a feature or schema change.
- Migrating
savedQueries's existing decodeStoredSavedQueries pattern itself.
Inherited from #586 (phase 1 of #593) — a lenient width parser
Surfaced by ChatGPT review pass 2 of PR #596 and verified. Folded here rather than fixed in #586
because it is exactly this issue's subject: fail-closed decoding of a persisted value.
#586 replaced a || chain with firstValidPx (src/state.ts:645) so a corrupt canonical width
no longer blocks a valid legacy fallback and no longer survives as NaN. That fixed the NaN
path, but the validator is parseInt, which is lenient:
| Stored value |
parseInt(raw, 10) |
Effect |
"420px" |
420 |
accepted; happens to recover the intent |
"1e3" |
1 |
accepted as 1, then clamped to the 320 floor |
"0x10" |
0 |
accepted as 0, then clamped to 320 |
"12abc" |
12 |
accepted as 12, then clamped to 320 |
So a genuinely corrupt canonical asb:rightInspectorPx still wins over a perfectly valid
asb:docPanePx/asb:cellDrawerPx, silently yielding the floor instead of the user's real saved
width. #586's own comment is honest about this ("the first candidate that parses to a finite
number, whatever its magnitude"), so the defect is a contract mismatch with the fail-closed
migration rule, not a lying comment.
Fix shape consistent with this issue: validate the complete trimmed string before conversion and
take the first fully valid finite candidate, preserving the documented precedence
(rightInspectorPx → docPanePx → cellDrawerPx → 480) among valid values only. Existing tests
use distinct values 500/420/560 and must keep passing.
Extra acceptance for this inherited item
Related
Part of the ADR-0004 vanilla-shell investment track (see
docs/ADR-0004-ui-shell.md); scoped from the 2026-08-03 architecture review.Goal
Give the five persisted-domain reads in
src/state.tsthat currently trust localStorage verbatim a fail-closed decoder each, following the patterndecodeStoredSavedQueriesalready establishes — drop malformed data to a safe default, never throw, never let a corrupt or hand-edited value reach the UI with the wrong shape.Context
src/state.ts:221–227decodessavedQueriesthrough a real decoder:Five other persisted reads in the same file have no equivalent and say so explicitly in their own comments — each is a raw
ascast straight fromread.loadJSON:varValuessrc/state.ts:696astrusts the localStorage shape verbatim — no decoder exists today (unlike savedQueries)."filterActivesrc/state.ts:706astrusts the localStorage shape verbatim — no decoder exists today."varRecentsrc/state.ts:717astrusts the localStorage shape verbatim — no decoder exists today."varRecentDisabledsrc/state.ts:722astrusts the localStorage shape verbatim — no decoder exists today."historysrc/state.ts:738astrusts the localStorage shape verbatim — no decoder exists today."Corrupt, truncated, or hand-edited localStorage for any of these five keys decodes to a wrongly-typed object/array that reaches the Workbench UI as-is (variable strip, optional-block filters, MRU dropdowns, executed-query history) — it fails open, not closed. This is the same defect family as the already-closed
#570(NaN-unsafeclampon the four numeric geometry preferenceseditorPct/sideSplitPct/cellDrawerPx/docPanePx), just on the object/array-shaped side of persisted state rather than the numeric side;#570's decoders never touched these five keys because they go throughread.loadJSON+as, notclamp.#570's current state: CLOSED (reason: Not Planned, closed 2026-08-03, no linked replacement or comment). This issue does not reopen#570— it is independent, narrower-scoped work addressing the object/array-shaped reads#570never covered. If#570's numeric-clamp defect is later judged worth resuming, it should be filed/reopened separately; this issue does not carry that scope.Deliverables
For each of the five fields, add a small fail-closed decoder module (or function, matching the granularity
decodeStoredSavedQueriesuses) that:varValues/filterActiveare plain string/boolean-keyed records;varRecentmatchesRecentMap's versioned shape;varRecentDisabledis a boolean;historyis an array of well-formedHistoryEntryobjects);{},emptyRecentMap(),false,[]) when the whole value is unusable;Replace each
ascast at the five sites above with a call to its decoder.Tests
loadJSON's own parse-failure path, and at least one "individual entry malformed, rest kept" case where the field is a collection) asserting the decoder falls back to the documented default rather than propagating the bad shape.tests/unit/state.test.tscontinues to pass unmodified in intent for well-formed input.npm test(coverage gate) andtsc --noEmitpass.Acceptance criteria
varValues,filterActive,varRecent,varRecentDisabled, andhistoryare each read through a fail-closed decoder, not a rawascast.historyarray,varRecentmap).npm test(coverage gate) andtsc --noEmitpass.Non-goals
editorPct,sideSplitPct,cellDrawerPx,docPanePx) orclamp's NaN-safety — that was#570's scope, now closed as Not Planned; this issue does not resume it.savedQueries's existingdecodeStoredSavedQueriespattern itself.Inherited from #586 (phase 1 of #593) — a lenient width parser
Surfaced by ChatGPT review pass 2 of PR #596 and verified. Folded here rather than fixed in #586
because it is exactly this issue's subject: fail-closed decoding of a persisted value.
#586 replaced a
||chain withfirstValidPx(src/state.ts:645) so a corrupt canonical widthno longer blocks a valid legacy fallback and no longer survives as
NaN. That fixed theNaNpath, but the validator is
parseInt, which is lenient:parseInt(raw, 10)"420px"420"1e3"1"0x10"0"12abc"12So a genuinely corrupt canonical
asb:rightInspectorPxstill wins over a perfectly validasb:docPanePx/asb:cellDrawerPx, silently yielding the floor instead of the user's real savedwidth. #586's own comment is honest about this ("the first candidate that parses to a finite
number, whatever its magnitude"), so the defect is a contract mismatch with the fail-closed
migration rule, not a lying comment.
Fix shape consistent with this issue: validate the complete trimmed string before conversion and
take the first fully valid finite candidate, preserving the documented precedence
(
rightInspectorPx→docPanePx→cellDrawerPx→ 480) among valid values only. Existing testsuse distinct values 500/420/560 and must keep passing.
Extra acceptance for this inherited item
firstValidPx(or its successor) rejects trailing junk, exponent form, hex form andwhitespace-only values instead of coercing them.
position.
Related
core/library-codec.ts'sdecodeStoredSavedQueries— the pattern this issue extends.docs/ADR-0004-ui-shell.md)