Skip to content

fix(ui): reset the transaction view state at the identity boundary - #279

Merged
argszero merged 1 commit into
mainfrom
fix/session-state-boundary
Sep 21, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/session-state-boundary

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

resetSessionCaches() — the identity boundary introduced by #251 (C2132) — clears only
Object.keys(Live). Its own comment states the intent it half-implements: "必须在会话结束
(exitGuest:登出 / 401)与会话建立(loadSession:boot / 登录)两侧都清空 —— 否则换账号后,
各视图会先用上一位用户的载荷渲染"
. But Live is not the only session state. The transaction view keeps
its state in module-level bindings, and neither side of the boundary resets them:

state declared role
txTable.sort / .filters / .page / .pageSize ui/js/app.js (const txTable = { … }) inputs to the request body (txQuerySig() + loadTransactions())
txTable.loadedPage / .loadedPageSize / .loadedQuerySig written in the load path the payload's cache-validity evidence
txRange / txCustomStart / txCustomEnd ui/js/app.js (let …) read by txQuerySig()

Neither boundary reloads the page (logout is api.clearToken(); exitGuest(); toast(…); login returns to
the hash view), so all of the above survive logout → login.

The loud face (page). The payload (Live.transactions) is cleared but its validity evidence is not,
so the next session's first render asks for a page that only existed for the previous user:

  1. renderTransactions() sees Live.transactions === null → draws the degraded state and calls
    loadTransactions();
  2. loadTransactions() reads txTable.page — the previous user's 5;
  3. the server answers honestly for this user: offset = (page-1)*page_size + LIMIT ? OFFSET ?
    (src/routes/wallet.rs) ⇒ items: [] while total: 3;
  4. #tx-count prints the honest total next to the body's empty state ⇒ "no matching records" beside
    "3 rows"
    ;
  5. the guard is then re-armed with the same wrong evidence, and the pager clamp runs inside a
    render ⇒ no corrective request is ever made (measured: exactly one tx-list request, face unchanged
    after 2.5 s). It heals only on some later, unrelated re-render.

The quiet face (filter/range). In the same hop a type=consume + 7-day start= window from the
previous session rides along ⇒ the new user's view is silently narrowed (measured: 1 of his 3 rows).

Severity, stated honestly — this is not a data leak. The request carries the previous session's
request inputs, but the server scopes every row to the authenticated user, so no other user's data is
ever fetched or displayed. What is wrong is that the new session's first screen of the transaction view
shows an empty table under a non-zero total, and does not heal. The defect is module-level UI state
surviving the identity boundary
— the same class #251 fixed for the Live slots.

Reachability: two accounts sharing one browser with the previous session left on the Transactions
view (an ordinary way to inspect an account on dev/prod). A hard refresh does not reproduce it (fresh
module state) — which is why it has stayed hidden.

Root cause and provenance — drift, not a deliberate trade-off

commit PR what it did
d70e032 #7 const txTable = { … } — the module-level view state (prototype UI)
052b60c #135 real server-side pagination → the guard + txTable.loaded* evidence
0f37903 #123 let txRange = "24h" (+ its siblings)
03b6be7 #262 txQuerySig() — the signature that reads txTable.filters and the three range bindings
a81839b #250 gave the dashboard its own slot; touched the evidence keys
0e4acc2 #251 the boundary itself: resetSessionCaches() — with a comment saying it exists so that one session's state is not inherited by the next … and handling only Live

So the author's intent is unambiguous and it is the one this PR implements: the boundary was introduced
one PR ago for exactly this reason, and Live was simply the subset it saw. There is no reading of the
history under which "the transaction view should inherit the previous session's page and time range" was
ever decided.

Changes

# File Change
E1 ui/js/app.js add resetTxView() (one named reset, beside the Live wipe) and call it from resetSessionCaches()
E2 src/state_gate.rs new invariant the_identity_boundary_resets_the_transaction_view_state (3 rules + derivation preconditions) + its ruler self-check the_session_state_scanners_have_teeth
E3 ui/README.md the identity-boundary section gains one bullet: the boundary clears module-level session state too, and why the evidence must die with the payload
E4 ui/index.html cache-bust the app bundle ?v= by procedure — read the live token (20260922-1) and write a strictly greater one (20260922-2)
E5 src/state_gate.rs amend a merged gate — see the next section; it is a required part of this PR, not a follow-up
— — no production Rust change; no new i18n key; no schema change
  function resetSessionCaches() {
    Object.keys(Live).forEach((k) => { Live[k] = null; });
    resetTxView();
  }

  // C2170:身份边界要清的不只是 `Live` —— 交易视图的状态是**模块级**的。前四项是载荷的**输入**
  // (`txQuerySig()` 的签名 + 页码 / 每页行数),后三项是载荷的**有效性证据**:证据属于载荷,
  // 载荷被清空而证据留下,守卫就会认一份**不存在**的载荷为「已加载」,下一位用户的首帧因此是
  // 空表(服务端按 `offset=(page-1)*page_size` 返回 `items: []`)且不自愈。复位取**声明处的默认
  // 值**,不能一键清空 —— `loadTransactions()` 用 `Math.max(1, txTable.pageSize || 10)` 兜底,
  // 清成 `undefined` 只会让每页退化到 1 行。
  function resetTxView() {
    txTable.sort = [];
    txTable.filters = {};
    txTable.page = 1;
    txTable.pageSize = 10;
    txTable.loadedPage = undefined;
    txTable.loadedPageSize = undefined;
    txTable.loadedQuerySig = undefined;
    txRange = "24h";
    txCustomStart = "";
    txCustomEnd = "";
  }

src/state_gate.rs also gains a # C2170 section in its module doc (that file documents every gate axis
there, including the honest limits), so the invariant's scope statement lives next to its code.

Behaviour change, recorded deliberately: the four inputs are reset to their declared defaults
rather than merely blanked. These are session-scoped view state, not persisted preferences — nothing about
them is written to localStorage — so nothing durable is lost.

Rejected alternatives (each rejected by a measurement, not by taste):

  • Reset only txTable.page — heals the loud face, leaves the quiet one (probe legs {Q2,Q3} stay red).
  • Clear everything to undefined/null — loadTransactions() clamps with
    Math.max(1, txTable.pageSize || 10), so a cleared pageSize degrades to 1 row per page.
  • Clear the evidence inside renderTransactions() — re-arms the guard on every render ⇒ request storm
    (C2146 already fought a storm of exactly this shape; measured again here: 300 requests and still asking).

E5 — this PR also amends an assertion in the already-merged C2131 gate

The merged gate the_transaction_cache_slot_has_one_writer_and_that_writer_records_the_evidence (#250,
C2131) asserts writers == holders: the functions that write Live.transactions must be exactly the
functions that write the evidence. This fix legitimately adds a holder — the boundary now resets the
evidence — while remaining the same set of writers of the payload slot (the slot is already cleared by
the generic Object.keys(Live) wipe, because transactions is a field of the Live literal). As written
the two invariants are unsatisfiable together.

The amendment narrows assert B to what it was always for, and keeps both halves:

writers ⊆ holders                       (unchanged purpose)
holders \ writers ⊆ boundary closure    (new: no STRAY evidence writer)

The extra-holder set is derived from this PR's own boundary closure (boundary_reset(...)), not a
hand-written roster; and the slot-producer roster assertion above it (writers == {loadTransactions}) is
left byte-identical — re-assigning Live.transactions in the reset would break it, which is why the
reset does not do that.

This is shipped in the same PR on purpose: the fix cannot land without it (the suite is red otherwise —
measured below as leg B), and a gate fragment must not be split from the change it gates.

Tests

  • cargo test — 318 → 320 passed / 0 failed (+2: the invariant and its ruler self-check).
  • cargo fmt --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean (0 errors).

Gate teeth — 12 isolated mutation legs, every one declared before it ran

Each leg compiles the landed src/state_gate.rs with a swapped-in ui/js/app.js (everything else,
including E5, is the shipped tree). Declared ⇒ measured, all as declared:

leg mutation C2170 C2131
A live (unfixed) app.js, E5 reverted rule 1 green
B this PR's fix, E5 reverted GREEN red
C live (unfixed) app.js, E5 in place rule 1 green
D this PR's fix, E5 in place (= the shipped tree) GREEN green
M1 reset only txTable.page (the narrow fix) rule 1 green
M2 full reset but blanked instead of the declared defaults rule 3 green
M3a keep the reset, add a stray evidence writer in renderTransactions() rule 2 red
M3b move the evidence writes out of the boundary, into renderTransactions() rule 1 red
M4 reset forgets txCustomStart / txCustomEnd rule 1 green
M5 comment out txTable.page = 1; rule 1 green
E rename the txTable literal (breaks the extractor) extractor green
N add an unrelated module-level let that txQuerySig() never reads GREEN green

⇒ every rule has a leg that opens it alone (a rule only ever observed on a tree where other rules also
fire has not been shown to have teeth); N is the negative control for "the roster is derived, not
hand-listed"; M5 shows comments are not evidence; A/B measure the E5 claim above instead of arguing
it; each leg ran 30 tests.

The gate, and what it actually proves

Required set is derived, in three parts (both sides of a discriminant need teeth):

  • (a) inputs — txTable.<field> for every field declared in the txTable object literal;
  • (b) evidence — every txTable.loaded<Ident> occurring in the file (prefix scan with a left boundary,
    so xtxTable.loaded… does not count);
  • (c) range state — the module-level lets read by txQuerySig() (declaration scan ∩ identifiers
    of the signature body);
rule asserts tooth (which leg opens it alone)
1 every name from (a)+(b)+(c) is assigned inside the resetSessionCaches() closure A, C, M1, M3b, M4, M5
2 writers(txTable.loaded*) ⊆ {loadTransactions} ∪ boundary closure M3a
3 the reset assigns the declared literals for page / pageSize / txRange M2
— non-empty preconditions on every derived set (an empty set makes rules 1–3 vacuous) E

Assignment detection anchors both sides: txTable.page is a prefix of txTable.pageSize and
txTable.loadedPage of txTable.loadedPageSize, so a left-anchored-only scan would accept "reset the
sibling" as evidence. Comments are stripped before any assertion — this fix itself writes an explanatory
comment beside the reset.

Honest limit 1 — the gate is lexical. It proves the boundary assigns those names and those literals;
it does not prove the assignments are unconditional, nor that the screen shows the new user's data (that
half is the probe below — this repo has no JS runtime in CI).

Honest limit 2 — the closure extractor had to be narrowed. The generic reachable() walk collects
callee names such as forEach / keys / push (call-graph leaves with no body), which made the
"declarations == closure size" self-check false on every tree. The helper keeps only names the file
actually declares, which is what makes that self-check meaningful rather than permanently red.

Honest limit 3 — the closure only follows function NAME(…) declarations. Writing the reset as an
arrow constant would hide its body from the closure ⇒ rule 1 fails (honestly red, never silently green).

Runtime half — jsdom probe over the committed ui/js/app.js

Real index.html + the four real scripts, only fetch stubbed, driven through the real navigation,
logout and login form (14 legs):

tree result
main's ui/js/app.js (--base, the defect present) 9 passed / 5 failed — {L6,L7,L8,Q2,Q3} = the axis, and only the axis
this PR's ui/js/app.js 14 passed / 0 failed
only-page reset (competitor) 12 passed / 2 failed — {Q2,Q3}
reset without the two custom-range lets (competitor) 14 passed / 0 failed — recorded honestly: the probe's legs do not cover that half; the gate's rule 1 (M4) does

What this PR deliberately does NOT do

  • No production Rust change, no API change, no schema change, no new i18n key.
  • activeView, isGuest, pendingHashView, mkExpanded are out of scope: they are re-derived by the
    router, managed by enterGuest()/exitGuest(), already registered as dead, or purely cosmetic. Only
    state that (i) shapes a request body and (ii) is not re-derived is covered — which is exactly what the
    (a)+(b)+(c) derivation yields.
  • No change to any persisted preference (dark mode / language / density are localStorage-backed and are
    not touched).

Related Issue

None — found by auditing the boundary introduced in #251.

Checklist

  • Branch name follows the convention (fix/session-state-boundary)
  • Commit message uses Conventional Commits (fix(ui): …)
  • Single responsibility, minimal change (one front-end site + a gate; no production Rust change)
  • cargo test passes (320 passed / 0 failed)
  • cargo fmt --check passes
  • clippy --all-targets -- -D warnings passes
  • New unit tests added (+2)
  • Config / data-structure changes are synced to the example files (none in this PR)

`resetSessionCaches()` cleared only the `Live` slots, while the transaction view's
state is module-level: `txTable.page/pageSize/filters/sort`, the time window
(`txRange`/`txCustomStart`/`txCustomEnd`) and the payload's validity evidence
(`txTable.loadedPage/loadedPageSize/loadedQuerySig`).  A page that survives logout is
invisible on screen, but the next user's first render re-fetches it
(`offset=(page-1)*page_size` => `items: []` while `total` stays non-zero), and nothing
heals it.

Add `resetTxView()` -- resetting to the *declared* defaults, because
`Math.max(1, txTable.pageSize || 10)` turns a blanked `pageSize` into one row per page --
and call it from `resetSessionCaches()`.  `src/state_gate.rs` gains
`the_identity_boundary_resets_the_transaction_view_state`, whose name set is derived (the
`txTable` literal's fields + `txTable.loaded*` + the module-level `let`s `txQuerySig()`
reads), with zero hand-written roster.

The merged C2131 gate asserted `writers == holders`.  The identity boundary is a
*resetter*, not a second producer, so its second assertion now requires the extra
evidence writer to lie in the boundary closure (derived from that closure, not a second
hand-written list) while its roster assertion stays byte-identical.  Without that
amendment the merged assertion flips red on this fixed tree -- measured, not argued.

Tests: `cargo test` 320 passed (was 318); `cargo fmt --check` and
`cargo clippy --all-targets -- -D warnings` clean.  Gate teeth measured on 12 isolated
mutation legs (one per rule) plus the E5-necessity pair; the runtime half on the jsdom
probe (unfixed `ui/js/app.js` 9 passed / 5 failed on the axis legs, committed tree 14/0).
@argszero

Copy link
Copy Markdown
Owner Author

Self-review note (the author cannot approve their own PR — leaving a comment instead, see the task's #331).

What I verified on the exact bytes that are in this PR (branch head 15f06f1, tree 04b06b021b4378641fa1cd0028914a9bcbe419c8):

  • cargo test — 320 passed / 0 failed (baseline on main fa6c399: 318).
  • cargo fmt --check — clean; cargo clippy --all-targets -- -D warnings — clean.
  • Gate teeth: 12 isolated mutation legs compiled against the landed src/state_gate.rs, each leg declared before it ran — ALL LEGS AS DECLARED. Every rule of the new gate opens alone on at least one leg. Legs A/B re-measure (rather than argue) the E5 claim: without the amendment to the merged C2131 assertion, the fixed tree is red.
  • Runtime half: the jsdom probe (real index.html + the four real scripts, fetch stubbed) — main's ui/js/app.js 9 passed / 5 failed with the red set exactly {L6,L7,L8,Q2,Q3}; this PR's ui/js/app.js 14 passed / 0 failed.
  • Cache-bust read live and bumped by procedure: js/app.js?v=20260922-1 → 20260922-2 (only ui/js/app.js changed, so the other bundles keep their tokens).

Known limits, recorded on purpose: the gate is lexical — it proves the boundary's closure assigns every derived name and returns to the declared literals; it does not prove the assignments are unconditional, and it does not prove the screen shows the new user's data (that is the probe's half; there is no JS runtime in CI). One competitor fix (reset without the two custom-range lets) is not rejected by the probe's legs — it is caught by the gate's rule 1 (M4), and the PR body says so instead of claiming the probe covers it.

Scope: no production Rust change, no new i18n key, no schema change, no persisted preference touched.

@argszero
argszero merged commit d640ef9 into main Sep 21, 2026
1 check passed
@argszero
argszero deleted the fix/session-state-boundary branch September 21, 2026 21:13
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant