fix(ui): read the spendable half of the wallet payload when refreshing your own balance - #261
Merged
Merged
Conversation
…g your own balance
The ops self-top-up refresh (`inlineOpsTopup`) fetched `/api/wallet` a second
time and took `w.balance` — the *permanent* half — where every other writer of
that fact takes `available = balance + gift_balance` (`wallet.rs`). Gift points
are spendable and expire (`gift.rs` really takes them away) and
`gift::ensure_daily_gift` runs on every authenticated request regardless of
role, so an ops member always has `gift_balance > 0`: topping up their own row
dropped the sidebar number by the gift amount (measured `balance=100 + gift=1`,
top-up +100 => screen 200, truth 201), it never self-heals (`loadSession` only
runs when the session is built), and the branch also bypassed the wallet
cache's single writer, leaving `Live.wallet` on the pre-top-up payload.
Fix: call `refreshWallet()` — the single writer of both `Live.wallet` and
`D.USER.balance` — instead of inventing a second source for the same fact.
New static gate
`state_gate::the_session_balance_has_one_source_and_it_is_the_spendable_half`,
three rules with independent teeth:
1. every absolute `D.USER.balance = …` must take `available` (relative
assignments and the literal `0` error fallback aside);
2. the set of functions that *fetch* the wallet payload is exactly
`{loadSession, refreshWallet}`;
3. `Live.wallet` still has exactly one writer, `refreshWallet`.
Rule 1 only looks at the first statement of the right-hand side: the common
shape here is "assign, then immediately print it", and judging the whole line
let the trailing *read* (`D.fmt(D.USER.balance)`) disguise `w.balance` as a
relative assignment — on the unfixed tree rule 1 stayed silent.
A/B (in-place mutations of `ui/js/app.js`, restored byte-exact):
- unfixed `/` variant (rule 1 + rule 2) and `m1` (refreshWallet reads the
permanent half) => rule 1 red;
- `m_inline` competitor (keep the extra fetch, just read `w.available`) =>
rule 2 red;
- `m3` (a second writer of `Live.wallet`) => rule 3 red.
DOM probe `tmp/c2145_probe.js` (jsdom, real page + 4 real scripts, `fetch`
stubbed and logged, drives the real navbar / ops tab / row button / inline
form): unfixed 9/9 as declared with exactly `A2`/`A3` red, fixed tree 9/9,
competitor 8/9 — rejected by `A3` (the cache is still stale). The probe pins
the direction; the static gate pins the shape, since CI has no JS runner.
`cargo test` 288 -> 289.
Owner
Author
|
Self-review (this task runs as Committer with Scope — one defect, one PR: the ops self-top-up branch read the permanent half of the wallet payload. 4 files, +246/−2, no production Rust changed (the only Rust change is a test-only static gate); no new i18n keys, no config or data-structure change. What I verified
Honest limitations
Awaiting CI. |
argszero
added a commit
that referenced
this pull request
Sep 21, 2026
Ships the 10 PRs merged since v0.7.25 (#261-#270). No schema change, no config change, so the deployment-side config.toml needs no edit. One fact, one source / display must equal what it consumes (frontend, 6 places) - #261 read the spendable half of the wallet payload when refreshing your own balance; #262 the transactions payload signature covers the time range, with one reload trigger shared by the four controls; #263 the settings controls are either wired or explicitly inert; #265 the re-list outcome comes from the same entry as its action; #268 the sharing form shows a plan's label, not its config id; #269 the ops card stops reading a key's status count as a health verdict. i18n reachability - #266 every pack key must reach a consumer (the gate), and #270 drops the 23 keys that gate proved unreachable: ZH/EN key count 811 -> 788, sunset list 59 -> 36. Gateway - #267 applies the body limit where axum actually reads it (per-route DefaultBodyLimit, 8 MiB on the three gateway routes; unauthenticated endpoints keep the 2 MiB default). This is the application half of rant 2026-09-18T09:14:18. It also corrects the false v0.7.10 "raised to 70MB" CHANGELOG line, which described installing a layer rather than raising a limit. - Cargo.toml / Cargo.lock: 0.7.25 -> 0.7.26. - CHANGELOG.md: v0.7.26 entry plus the v0.7.10 correction. - ui/index.html cache-bust left as-is: this release touches no UI file; the live values are app.js 20260921-2 / i18n.js 20260921-2. cargo test 302 passed; cargo fmt --check clean; clippy -D warnings clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The ops self-top-up refresh read the wrong half of the wallet payload.
D.USER.balanceis the client-side carrier of "how many spendable points do I have left" — the sidebar, the wallet view and the chat footer all print it. Its definition is given by the product:Every writer of that fact honours it —
loadSession(w.available),refreshWallet(Live.wallet.available), and the two clearly-labelled demo paths (relative moves) — except one:w.balanceis the permanent half.gift::ensure_daily_gift(GIFT_DAILY_AMOUNT = 1.0) runs on every authenticated request and onGET /api/wallet, regardless of role, so an ops/admin member always hasgift_balance > 0. Topping up their own row therefore dropped the displayed spendable balance by the gift amount, it never self-heals (loadSessiononly runs when the session is built), and the branch also bypassed the wallet cache's single writer, leavingLive.walleton the pre-top-up payload.balance=100,gift=1).Live.wallet.availableis still 101.Related Issue
None — found by this task's Recon pass (axis C2145).
Changes
ui/js/app.js: the self-refresh branch now callsrefreshWallet()— the single writer of bothLive.walletandD.USER.balance— instead of inventing a second source for the same fact.src/state_gate.rs: new static gatethe_session_balance_has_one_source_and_it_is_the_spendable_half, three rules with independent teeth: (1) every absoluteD.USER.balance = …must takeavailable(relative moves and the literal0error fallback aside); (2) the set of functions that fetch the wallet payload is exactly{loadSession, refreshWallet}; (3)Live.walletstill has exactly one writer. Rule 1 only inspects the first statement of the right-hand side — judging the whole line let the trailing read (D.fmt(D.USER.balance)) disguisew.balanceas a relative assignment.ui/index.html: cache-bust token forapp.js.ui/README.md: the convention (one source, and it is the spendable half).Tests
cargo test— 289 passed (288 before), including the new gate and its synthetic self-tests.cargo fmt --checkclean;cargo clippyshows only the pre-existingprotocol.rs:662warning.node --check ui/js/app.jsclean.New tests added. Static gate above, plus:
ui/js/app.js, restored byte-exact; app.js md5329a4285411c2644174454f48e1ccceabefore and after): unfixed/variant → rule 1 red;m1(refreshWallet reads the permanent half) → rule 1 red;m_inlinecompetitor (keep the extra fetch, just readw.available) → rule 2 red;m3(a second writer ofLive.wallet) → rule 3 red. Each rule has a mutation that only it rejects.tmp/c2145_probe.js, jsdom, realindex.html+ the four real scripts,fetchstubbed and logged, drives the real navbar / ops tab / per-row button / inline form,expectdeclared per check): unfixed tree 9/9 as declared with exactlyA2/A3red (side="200"vs201;Live.wallet.available = 101), fixed tree 9/9, competitor 8/9 — rejected byA3. Control legs: baseline equalsavailable; topping up another row leaves your own number alone; arole=usersession has no ops entry and issues no ops request. The probe pins the direction; the static gate pins the shape, because CI has no JS runner.Checklist
fix/…)