fix(ui): reset the transaction view state at the identity boundary - #279
Conversation
`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).
|
Self-review note (the author cannot approve their own PR — leaving a comment instead, see the task's What I verified on the exact bytes that are in this PR (branch head
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 Scope: no production Rust change, no new i18n key, no schema change, no persisted preference touched. |
Summary
resetSessionCaches()— the identity boundary introduced by #251 (C2132) — clears onlyObject.keys(Live). Its own comment states the intent it half-implements: "必须在会话结束(
exitGuest:登出 / 401)与会话建立(loadSession:boot / 登录)两侧都清空 —— 否则换账号后,各视图会先用上一位用户的载荷渲染". But
Liveis not the only session state. The transaction view keepsits state in module-level bindings, and neither side of the boundary resets them:
txTable.sort/.filters/.page/.pageSizeui/js/app.js(const txTable = { … })txQuerySig()+loadTransactions())txTable.loadedPage/.loadedPageSize/.loadedQuerySigtxRange/txCustomStart/txCustomEndui/js/app.js(let …)txQuerySig()Neither boundary reloads the page (logout is
api.clearToken(); exitGuest(); toast(…); login returns tothe 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:
renderTransactions()seesLive.transactions === null→ draws the degraded state and callsloadTransactions();loadTransactions()readstxTable.page— the previous user's 5;offset = (page-1)*page_size+LIMIT ? OFFSET ?(
src/routes/wallet.rs) ⇒items: []whiletotal: 3;#tx-countprints the honest total next to the body's empty state ⇒ "no matching records" beside"3 rows";
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-daystart=window from theprevious 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
Liveslots.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
d70e032const txTable = { … }— the module-level view state (prototype UI)052b60ctxTable.loaded*evidence0f37903let txRange = "24h"(+ its siblings)03b6be7txQuerySig()— the signature that readstxTable.filtersand the three range bindingsa81839b0e4acc2resetSessionCaches()— with a comment saying it exists so that one session's state is not inherited by the next … and handling onlyLiveSo 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
Livewas simply the subset it saw. There is no reading of thehistory under which "the transaction view should inherit the previous session's page and time range" was
ever decided.
Changes
ui/js/app.jsresetTxView()(one named reset, beside theLivewipe) and call it fromresetSessionCaches()src/state_gate.rsthe_identity_boundary_resets_the_transaction_view_state(3 rules + derivation preconditions) + its ruler self-checkthe_session_state_scanners_have_teethui/README.mdui/index.html?v=by procedure — read the live token (20260922-1) and write a strictly greater one (20260922-2)src/state_gate.rssrc/state_gate.rsalso gains a# C2170section in its module doc (that file documents every gate axisthere, 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):
txTable.page— heals the loud face, leaves the quiet one (probe legs{Q2,Q3}stay red).undefined/null—loadTransactions()clamps withMath.max(1, txTable.pageSize || 10), so a clearedpageSizedegrades to 1 row per page.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 writeLive.transactionsmust be exactly thefunctions 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, becausetransactionsis a field of theLiveliteral). As writtenthe two invariants are unsatisfiable together.
The amendment narrows assert B to what it was always for, and keeps both halves:
The extra-holder set is derived from this PR's own boundary closure (
boundary_reset(...)), not ahand-written roster; and the slot-producer roster assertion above it (
writers == {loadTransactions}) isleft byte-identical — re-assigning
Live.transactionsin the reset would break it, which is why thereset 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.rswith a swapped-inui/js/app.js(everything else,including E5, is the shipped tree). Declared ⇒ measured, all as declared:
app.js, E5 revertedapp.js, E5 in placetxTable.page(the narrow fix)renderTransactions()renderTransactions()txCustomStart/txCustomEndtxTable.page = 1;txTableliteral (breaks the extractor)letthattxQuerySig()never reads⇒ 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);
Nis the negative control for "the roster is derived, nothand-listed";
M5shows comments are not evidence;A/Bmeasure the E5 claim above instead of arguingit; 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):
txTable.<field>for every field declared in thetxTableobject literal;txTable.loaded<Ident>occurring in the file (prefix scan with a left boundary,so
xtxTable.loaded…does not count);lets read bytxQuerySig()(declaration scan ∩ identifiersof the signature body);
resetSessionCaches()closurewriters(txTable.loaded*) ⊆ {loadTransactions} ∪ boundary closurepage/pageSize/txRangeAssignment detection anchors both sides:
txTable.pageis a prefix oftxTable.pageSizeandtxTable.loadedPageoftxTable.loadedPageSize, so a left-anchored-only scan would accept "reset thesibling" 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 collectscallee 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 anarrow 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.jsReal
index.html+ the four real scripts, onlyfetchstubbed, driven through the real navigation,logout and login form (14 legs):
main'sui/js/app.js(--base, the defect present){L6,L7,L8,Q2,Q3}= the axis, and only the axisui/js/app.jspagereset (competitor){Q2,Q3}lets (competitor)M4) doesWhat this PR deliberately does NOT do
activeView,isGuest,pendingHashView,mkExpandedare out of scope: they are re-derived by therouter, managed by
enterGuest()/exitGuest(), already registered as dead, or purely cosmetic. Onlystate that (i) shapes a request body and (ii) is not re-derived is covered — which is exactly what the
(a)+(b)+(c) derivation yields.
localStorage-backed and arenot touched).
Related Issue
None — found by auditing the boundary introduced in #251.
Checklist
fix/session-state-boundary)fix(ui): …)cargo testpasses (320 passed / 0 failed)cargo fmt --checkpassesclippy --all-targets -- -D warningspasses