Skip to content

fix(tx): write the payload receipt from the request that issued it - #289

Merged
argszero merged 1 commit into
mainfrom
fix/tx-receipt-own-signature
Sep 22, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-receipt-own-signature

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

loadTransactions() stamps three receipts for the payload it just loaded
(txTable.loadedPage / loadedPageSize / loadedQuerySig), and
renderTransactions() compares them with the current controls to decide whether to
re-query. The first two are captured before the await — they belong to the request.
The signature was not: it was read from live state when the response landed
(txTable.loadedQuerySig = txQuerySig();), so the guard compared the receipt against
its own projection and was satisfied by construction.

Consequence: two control changes inside one RTT put two requests with different payloads
in flight. The superseded one carries no identity, so if it lands last it is adopted and
certified as current
— the table keeps the rows of a filter the user has already left, and
nothing ever re-checks (zero further requests). Reached by any two changes inside one RTT
(the top tabs, the type column filter, the range select, the custom start/end inputs — all
four go through the same trigger reloadTransactions(); the text filter's 300 ms debounce
can also overlap an in-flight request).

Related Issue

None. (Reported and instrumented inside this repository's own defect-hunting loop.)

Changes

  • ui/js/app.js: capture the signature before the request goes out
    (const reqSig = txQuerySig();) and stamp that value — exactly the shape its two sibling
    receipts already had. Net effect: one captured identifier + one changed right-hand side.
  • src/state_gate.rs: new gate the_receipt_is_written_by_the_request_that_issued_it
    (R1 no receipt right-hand side may be a call; R2 a bare-identifier right-hand side must be
    bound in the same function before its first await; R3 the signature receipt must be
    initialised from the very function the guard compares against; R4 reverse/non-empty — the
    guard must still compare against a call, and the roster/write sites must stay non-empty).
    All four expectations are derived from the file (roster from txTable.loaded*,
    the compared function from the guard line), not spelled out as this edit's literals.
    Companions: the_r155_rules_have_teeth (four synthetic mutants, each flipping exactly one
    rule), the_r155_rules_separate_the_variants, and
    the_r155_extractor_lands_on_real_function_bodies.
  • ui/index.html: cache-bust token for app.js.
  • ui/README.md: the convention, plus an honest scope note.

Tests

  • cargo test — 365 passed, 0 failed (361 before; the four new tests are added)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New tests added (the gate + its three companions)

Instrument 1 — compiled gate (A/B over two trees). With the fix removed
(ui/js/app.js md5 c6ca93107395…, i.e. the parent commit's bytes) and the landed gate
compiled against it: 69 passed; 1 failed and the one failure is exactly
the_receipt_is_written_by_the_request_that_issued_it (R155 未修:收据在响应落地后读活状态,
r1bad=["line 1827: txTable.loadedQuerySig = txQuerySig()"]). On the landed bytes the same
gate is green.

Instrument 2 — jsdom probe (real four scripts, real controls, real fetch, parked
responses released in a chosen order; the "user's intent" is the click the instrument itself
drove, never an indicator read back out of the page). Re-run against the landed bytes:
5/5 trees as declared.

tree red legs verdict
landed (app.js md5 c6883a954598…) — all 8 legs pass
base (fix removed) A1,A2,A3 rejected
generation guard only — accepted (terminal state correct)
m1 ignore the change while in flight P0,P0b,A1,A2,A3,C2 rejected twice over
m2 pull the controls back to what was loaded A1,A2,A3 rejected

Landed reading: rows belong to the current filter (consume)=true, #tx-count=3,
consume-list requests=2 (the app re-asked). Base reading: table says earn=true while the
clicked filter was consume, #tx-count=2 expected 3, consume-list requests=1 (the app
never re-asked).

Known asymmetry (deliberate, measurable). The two instruments judge the
generation-guard-only variant differently: the probe accepts it (terminal state correct, and
it even saves a request), the gate rejects it (R1) — "who authored the receipt" is one claim
on this axis, and a receipt kept by a second mechanism alone is not the defect fixed. That is
recorded in ui/README.md rather than smoothed over.

Checklist

  • Branch naming (fix/…)
  • Conventional Commits commit message
  • Single responsibility, minimal diff (the production change is one captured identifier
    and one changed right-hand side; the rest is the gate, the prose and the cache-bust)

`loadTransactions()` stamps three receipts for the payload it just loaded
(`txTable.loadedPage` / `loadedPageSize` / `loadedQuerySig`), and
`renderTransactions()` compares them with the *current* controls to decide
whether to re-query.  The first two are captured before the `await` — they
belong to the request.  The signature was not: it was read from live state
when the response landed, so the guard compared the receipt against its own
projection and was satisfied by construction.

Two control changes inside one RTT therefore put two requests with different
payloads in flight, and the superseded one carries no identity — if it lands
last it is adopted *and certified as current*.  The table then keeps the rows
of a filter the user has already left, and nothing ever re-checks.

Capture the signature before the request goes out (`const reqSig =
txQuerySig();`) and stamp that value, like its two sibling receipts.

Companion gate in `src/state_gate.rs` (four derivable rules: no receipt RHS
may be a call; a bare-identifier RHS must be bound in the same function before
its first `await`; the signature receipt must be initialised from the very
function the guard compares against; and the guard/roster must stay non-empty).
Both instruments were re-run against the landed bytes: the compiled gate is
green here and red on exactly the axis test with the fix removed, and the
jsdom probe is 5/5 trees as declared (fix + generation-guard green; unfixed,
in-flight-skip and cosmetic-sync rejected).
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (author, committer with allow_self_merge)

Re-read the diff at 2ab740b. The production change is deliberately the smallest one that removes
the defect: one captured identifier before the await, and the receipt's right-hand side changed
from a call to that identifier. The remaining three receipts/guard sites are untouched, and no new
state was introduced — so the fix's blast radius is loadTransactions().

Checks re-run locally on the branch tip (2ab740b, ui/js/app.js md5 c6883a954598…):

  • cargo test → 365 passed; 0 failed (361 before this PR; +4 from the new gate and its companions)
  • cargo fmt --check → clean
  • cargo clippy --all-targets -- -D warnings → clean (the CI invocation from .github/workflows/ci.yml)

Two extra checks worth recording, because both are claims the diff makes in prose:

  1. The gate's axis test really is the only thing the defect flips. Materialised the parent tree
    (5e67055, ui/js/app.js md5 c6ca93107395…) with the landed gate compiled against it:
    69 passed; 1 failed, the single failure being the_receipt_is_written_by_the_request_that_issued_it
    with r1bad=["line 1827: txTable.loadedQuerySig = txQuerySig()"]. Same gate on the landed bytes: green.
  2. r155_variant_unfixed reproduces the pre-fix file byte-for-byte (the doc comment claims it).
    Measured: applying the variant's two replacements to the landed ui/js/app.js yields 206 981 bytes
    that compare equal to git show 5e67055:ui/js/app.js. That is why the capture constant carries its
    explanatory comment: the "unfixed" tree must not be left with prose describing a fix that is gone.

The jsdom probe's per-tree readings (5/5 as declared, re-generated from the landed bytes) are in the
description, including the one place where the two instruments deliberately disagree (the
generation-guard-only variant: probe accepts, gate rejects — the receipt's author is the claim here,
and a receipt kept by a second mechanism alone does not fix it).

No further changes requested from myself; merging at CI green.

@argszero
argszero merged commit 9fd93ee into main Sep 22, 2026
1 check passed
@argszero
argszero deleted the fix/tx-receipt-own-signature branch September 22, 2026 13:14
@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