Skip to content

fix(ui): make the transactions payload signature cover the time range - #262

Merged
argszero merged 1 commit into
mainfrom
fix/tx-query-signature-covers-range
Sep 15, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-query-signature-covers-range

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The transactions view decides "is the cached payload still valid?" in exactly one
place — the guard in renderTransactions(), which compares page / page size / the
payload signature. The signature hashed txTable.filters (column filters) only,
while the list and trend URLs are rendered from a second piece of state: the time
range (txRange / txCustomStart / txCustomEnd). A range-only change was
therefore invisible to the guard, so each of the three range handlers paired the
render with an explicit loadTransactions().

That patch double-fetches whenever the guard also fires, and #tx-range resets the
page before rendering — so from page ≥ 2, loadedPage !== page makes the guard
fetch once and return, and then the explicit call fetches the same URL again: two
byte-identical list requests plus two trend requests
for one change of one select.
On page 1 only one request goes out, which is why this stayed hidden until now.

Changes

  • txFilterSig()txQuerySig(): the payload signature now covers every
    input that changes the request body — the column filters and the three range
    state values. It must read the state values; deriving it from txRangeParams()
    would embed a now − window timestamp that differs on every call, making the
    signature change constantly and turning the guard into a per-render request storm
    (worse than the original defect — see the probe numbers below).
  • txTable.loadedFilterSigloadedQuerySig (guarded at the guard, written by the
    loader).
  • New reloadTransactions() — the single reload trigger: resetting the page and
    reloading are one action. All four controls go through it (type tabs, range quick
    pick, custom start, custom end); the explicit loadTransactions() calls are gone
    from the wiring, so a control change costs at most one request. Its own fetch
    stays guarded by an empty slot (first visit / retry path, where the renderer only
    draws the degraded state).
  • New static gate state_gate::the_transaction_payload_has_one_signature_and_one_reload_trigger
    with four rules, each with its own teeth: the signature reads the range state and
    is not derived from txRangeParams(; the control-wiring function calls no loader
    directly; the "resets the page + fetches" function is unique and shared by the four
    controls; and its explicit fetch stays guarded.
  • ui/README.md convention section; ui/index.html cache-bust app.js?v=…-15.

The sibling control (type tabs) was already fixed this way in C2112 for the same
reason — this closes the range half.

Related Issue

None.

Tests

  • cargo test — 289 → 290 passed, 0 failed
  • cargo fmt --check clean
  • New unit test added (src/state_gate.rs), with a self-verifying extractor

A/B (both instruments, run locally)

Static gate (tmp/c2147_gate_ab.py, mutating ui/js/app.js in place and
restoring it byte-exactly, md5 verified each leg) — 7 mutation legs, each lighting
up exactly the intended rule and nothing else:

leg mutation result
m0 none PASS
m1 signature drops the range state FAIL — "signature does not read txRange"
m2 signature = cols + txRangeParams() (the tempting fix) FAIL — "signature does not read txRange"
m2b signature keeps the state and adds txRangeParams() FAIL — "derived from txRangeParams("
m3 range handler keeps an explicit loadTransactions() FAIL — "the wiring calls loadTransactions() itself"
m4 a second function resets the page and fetches FAIL — "expected exactly one"
m5 trigger loses the empty-slot guard FAIL — "not guarded by an empty slot"

On the unfixed tree (git show HEAD:ui/js/app.js) the gate is red for the right
reason (txFilterSig does not read txRange).

Runtime probe (tmp/c2146_probe.js, jsdom booting the real ui/index.html plus
the four real scripts, stubbing and logging every fetch, driving the real
controls) — 10 legs:

  • this tree: 10/10 as declared
  • unfixed tree: exactly the A1/B1 axis legs red, and the two list requests are
    byte-identical; every control leg green on both trees
  • the tempting wrong fix (signature derived from txRangeParams()) is rejected by
    the probe: the request count grows leg over leg (3→3→2→4→5) — a self-feeding storm

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal diff (4 files, no new i18n keys)

`renderTransactions()` holds the only "is the cached payload still valid?" guard:
it compares page, page size and the payload signature. The signature hashed
`txTable.filters` (column filters) only, while the list and trend URLs are
rendered from a *second* piece of state — the time range (`txRange` /
`txCustomStart` / `txCustomEnd`). A range-only change was therefore invisible to
the guard, so each of the three range handlers paired the render with an explicit
`loadTransactions()`.

That patch double-fetches whenever the guard also fires, and `#tx-range` resets
the page *before* rendering: from page >= 2, `loadedPage !== page` makes the guard
fetch once and return, and then the explicit call fetches the same URL again.
Two byte-identical list requests plus two trend requests for a single change of
one select. On page 1 only one request goes out, which is why it stayed hidden.

Make the signature cover every input that changes the request body:

* `txFilterSig()` -> `txQuerySig()`, now hashing the column filters *and* the
  three range state values. It must read the state values — deriving it from
  `txRangeParams()` would embed a `now - window` timestamp, changing on every
  call and turning the guard into a per-render request storm.
* `txTable.loadedFilterSig` -> `loadedQuerySig`.
* New `reloadTransactions()`: the single reload trigger (resetting the page and
  reloading are one action). All four controls — type tabs, range quick pick,
  custom start, custom end — go through it, and the explicit
  `loadTransactions()` calls are gone from the wiring, so a control change costs
  at most one request (its own fetch stays guarded by an empty slot, for the
  first visit and the retry path).
* New static gate
  `state_gate::the_transaction_payload_has_one_signature_and_one_reload_trigger`
  pins that shape in CI (there is no JS runner there). It judges
  comment-stripped bodies, and matches the loader by identifier — the two symbols
  are prefixes of each other (`reloadTransactions()` ends with
  `loadTransactions()`), so a substring test would red every fixed tree.

The sibling control (type tabs) was already fixed in C2112 for exactly this
reason; this closes the range half.

Tests: `cargo test` 289 -> 290; `cargo fmt --check` clean; clippy reports only the
pre-existing `protocol.rs:662`. Verified with `tmp/c2146_probe.js` (jsdom, real
scripts, logged `fetch`): 10/10 on this tree, and exactly the `A1`/`B1` axis legs
red on the unfixed tree, where the two list requests are byte-identical.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (author, allow_self_merge: true; gh pr review --approve cannot be used on my own PR — a comment is the vehicle).

What the diff does, in three parts

  1. txQuerySig() (was txFilterSig()) is now the single statement of "what decides the transactions payload": the sorted column filters plus txRange / txCustomStart / txCustomEnd — state values only. loadedFilterSigloadedQuerySig at both ends (guard + evidence write).
  2. reloadTransactions() is the one reload trigger (page reset + reload as a single action). All four controls call it; no control calls loadTransactions() itself. Its own fetch is guarded by !Live.transactions so the first visit and the retry path still work, while a non-empty slot defers to the guard.
  3. state_gate::the_transaction_payload_has_one_signature_and_one_reload_trigger pins that shape in CI, plus a ui/README.md section.

Evidence

  • cargo test: 289 → 290 passed, 0 failed. cargo fmt --check clean; clippy reports only the pre-existing protocol.rs:662.
  • Gate A/B: 7 in-place mutation legs (each restoring ui/js/app.js byte-exactly, md5 checked), each lighting up exactly its own rule — including the competitor fixes: "signature = cols + txRangeParams()" (the tempting one), "keep an explicit loadTransactions() alongside the fixed signature", and "add a second trigger path". On the unfixed tree the gate is red for the right reason.
  • Runtime probe (jsdom, real index.html + the four real scripts, every fetch logged, real controls driven): 10/10 here; on the unfixed tree exactly A1/B1 red with two byte-identical list URLs. The txRangeParams()-derived competitor is rejected by the probe (request count 3→3→2→4→5 across legs — a self-feeding storm).

Honest limits

  • Rule 1 of the gate is lexical: it proves the signature function reads the three range state identifiers and is not derived from txRangeParams(. It does not prove the arithmetic — a hypothetical + txRange − txCustomStart would slip through. Arithmetic is covered by the probe on the tree it ran on, not in CI (there is no JS runner there) — the same trade-off as the earlier state_gate invariants.
  • uses >= 4 is a lower bound on how many times the wiring function calls the trigger (today: 4). It catches "one control forgot to go through the trigger", not "a fifth control was added without wiring".
  • Intentional behaviour change: the type-tab handler previously did txTable.page = 1; renderTransactions(), which on an empty slot only redrew the degraded state. It now goes through the trigger and fetches. That is the point of the trigger (page reset and reload are one action) and it makes the tab behave like the three range controls; probe leg G1 covers "range change on an empty slot still fetches exactly once".
  • Two extractor hazards are self-verified inside the gate because they bit during development: code_body() strips comments (my own explanatory comment next to the control mentions loadTransactions() — raw-text judging reddens the fixed tree), and mentions_tx_loader() matches by identifier (reloadTransactions() ends with loadTransactions()).

@argszero
argszero merged commit 03b6be7 into main Sep 15, 2026
1 check passed
@argszero
argszero deleted the fix/tx-query-signature-covers-range branch September 15, 2026 04:46
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