fix(ui): re-list outcome must come from the same entry as the action - #265
Conversation
A sharing key has three reachable statuses (`on` / `paused` / `off`): PATCH /api/sharings/:id accepts all three, and GET /api/sharings applies no status filter, so a soft-deleted row stays in the list. The row button labels the transition from the CURRENT status (Pause / Resume / Re-list), while the outcome toast picked it from the NEXT status with a two-valued ternary (`s.status === "on" ? "paused" : "on"`) -- so `off -> on` (re-list) was reported as a resume: button "Re-list", toast "Resumed sharing of ...". The key naming that branch, `share.toggle.relisted`, is present in both language packs and unreachable -- an unreachable key is the fingerprint of a lost branch. Provenance is drift, not a trade-off: 68f9f70 (#86) introduced the three-way button, 89963f3 (#94) replaced the mock's three-branch toggle with the two-branch ternary. - add `SHARE_TOGGLE` (status -> { label, next, outcome }): the button's action, its next status and its outcome text now come from the SAME entry, read by both consumers through `shareToggle(s.status)`. - new static gate `state_gate::the_sharing_toggle_outcome_comes_from_the_same_entry_as_its_action`: share.toggle.* literals may only live inside the table; the table's key set must equal `SHARE_STATUS`'s (both derived from source); the label/outcome columns must be unique per entry; and the handler (derived as the endpoint that sends a NON-literal status to /api/sharings/) must not name a share.toggle.* key nor branch on a status literal, and must share exactly one accessor with the button markup. Extractor self-proofs use synthetic inputs. - i18n positive controls recalibrated from what the gate reported: T() literals 542 -> 537, distinct 433 -> 428 (five literal call sites became table entries). - ui/README.md: rule + measured scope; index.html cache-bust for app.js.
26e45fb to
32e9f18
Compare
Self-review (Committer,
|
| leg | declared | actual |
|---|---|---|
V0 unfixed tree |
RED · rule 1 | RED — 5 literals outside the table |
M1 inline literal next to the button |
RED · rule 1 | RED — 1 literal outside |
M2 table key renamed (paused→pausing) |
RED · rule 2 | RED — key set ≠ SHARE_STATUS |
M2b two outcomes folded onto one key |
RED · rule 1b | RED — outcome duplicated by paused/off |
M2c next points at its own status |
RED · rule 1b | RED — off → off |
M3 handler branches on a status literal |
RED · rule 3 | RED — "on" in the handler |
M4 competitor: fold the button to two values so both sides "agree" |
RED · rule 1 | RED — 2 literals outside |
M5 button stops reading the table |
RED · rule 3 | RED — no shared accessor |
FIX this branch |
GREEN | GREEN |
V0 is red only on the target-axis rule (no collateral damage on the other rules).
DOM probe — tmp/c2153_probe.js (jsdom, real index.html + the four real scripts, fetch
stubbed and logged, driven through real nav → real row button → real PATCH, reading
#toast-wrap, expect declared per leg), 10 legs:
- unfixed tree (
822a5700…) → 10/10 as declared, exactlyA2/A3/Z1red in both packs
(en: button"Re-list"/ toast"Resumed sharing of kimi-k3"; zh likewise); - this branch → 10/10 green (toast
"Re-listed kimi-k3"/"已重新上架 kimi-k3"); - competitor
M4(fold the button so both sides "agree") → 5/10, red{P1,A2,A3,B1,Z1}— the
instrument pins the direction, so "make them agree by collapsing the button" is rejected.
The "just delete the key" escape is also rejected: assertions are on rendered text, not key names.
Reachability of the key
Post-fix, share.toggle.relisted is reachable, so the unreachable-key baseline moves 60 → 59.
The five T("share.toggle.*") call-site literals removed by the fix are why i18n_pack.rs's
T_LITERAL_COUNT / T_LITERAL_DISTINCT dropped 542→537 / 433→428 (counts taken from the gate's
own report, not hand-computed).
Notes
- No config / data-structure change ⇒ no example file touched.
cargo test295 passed;cargo fmt --checkclean; clippy only the pre-existing
protocol.rs:662warning. CI green on32e9f18.
The three existing pack gates all ask the same direction: is every key that IS referenced present in both packs? Nothing asked the other question, so a key nobody references is invisible to CI -- and an unreachable key is not harmless: `share.toggle.relisted` sat in both packs unreachable, and it was the fingerprint of a lost branch (the re-list outcome, fixed in #265). This adds `every_pack_key_reaches_a_consumer`: the whole pack must be reachable, with today's residue frozen as a sunset list rather than a registry exemption. - `src/i18n_pack.rs` (test-only: `main.rs` compiles it as `#[cfg(test)] mod`): - `ui/js/data.js` joins the consumer corpus; - the predicate: a key is reachable iff it occurs at a key-token boundary in the comment-stripped corpus (`app.js` / `api.js` / `data.js`, `index.html`, and the code parts of `i18n.js` outside its pack sections) or begins with a dynamic prefix derived from the corpus itself (`share.day.`); - `UNREACHABLE_PACK_KEYS`: the sunset list, 59 keys, each labelled with the class the census arrived at; - the gate asserts the computed set == the declared set EXACTLY: a new unreferenced key, a stale entry, or a wired-up sunset key all turn it red; - the predicate self-proof runs on synthetic inputs. - `ui/README.md`: the rule, the CI coverage and the scope (the gate pins the list, it deletes no key). No production code path changes, no config or data-structure change.
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.
Summary
A sharing key has three reachable statuses:
PATCH /api/sharings/:idacceptson/paused/off(
offis a soft delete), andGET /api/sharingsapplies no status filter — so a soft-deleted rowstays in the list. The row button labels the transition from the current status
(
Pause/Resume/Re-list), but the outcome toast picked it from the next status with atwo-valued ternary:
So
off → on(re-list) was reported as a resume: the button said "Re-list" while the toast said"Resumed sharing of …". The key naming that branch,
share.toggle.relisted, is present in bothlanguage packs and unreachable — an unreachable key is the fingerprint of a lost branch.
Provenance is drift, not a trade-off:
68f9f70(#86) introduced the three-way button, and89963f3(#94, "zero mock data") replaced the mock's three-branch toggle (which did toast
relisted) with thetwo-branch ternary — the button kept three values, the outcome collapsed to two.
Reproduce with no code change: sharing page → delete a key (soft delete →
off, the row stays) →the button reads "重新上架 / Re-list" → click → the toast claims "已恢复 …" / "Resumed sharing of …".
Related Issue
Changes
ui/js/app.js: add a transition tableSHARE_TOGGLE(status → { label, next, outcome }) so thebutton's action, its next status and its outcome text come from the same entry, read by
both consumers through
shareToggle(s.status). Structurally the two halves can no longer diverge.src/state_gate.rs: new static gatethe_sharing_toggle_outcome_comes_from_the_same_entry_as_its_actionwith four rules, each with its owntooth:
share.toggle.*literal inapp.jslives inside the table (a literal outside = somebodypicking an outcome by hand);
SHARE_STATUS's (both derived from source — no hand-writtenroster),
label/outcomeare unique per entry,nextnever points at its own status, and everyreferenced key exists in both packs;
/api/sharings/) must notname a
share.toggle.*key, must not branch on a status literal, and must share exactly oneaccessor with the button markup;
The extractor self-proofs use synthetic inputs only (comments are stripped first — the fix itself writes
an explanatory comment mentioning the old two-valued ternary; braces inside strings must not pair;
nested-object keys are not outer keys;
xlabelis notlabel), plus two cross-checks: the two keyextractors must agree, and the shared-accessor discriminator must be empty on the defect shape and
return the accessor on the fixed shape.
ui/README.md: new section documenting the rule and the measured scope (the gate is lexical — itproves the outcome comes from the table, not that
outcome/labelare the right columns; that part iscovered by the DOM probe below, since CI has no JS runner).
ui/index.html: cache-bust forapp.js(?v=20260915-16).No config / data-structure changes, so no example file needed updating.
Tests
cargo testpasses — 295 passed (was 294; +1 new gate)cargo fmt --checkpassesA/B (in-place mutations of the gate, every leg declaring its expectation — declared-vs-actual and
PASS/FAIL printed as two independent facts; 9/9 as declared):
V0unfixed treeshare.toggle.*literals outside the tableM1inline literal next to the buttonM2rename a table key (paused→pausing)SHARE_STATUSM2bfold two outcomes onto one keyoutcomeduplicated bypaused/offM2cnextpoints at its own statusoff→offM3handler branches on a status literal"on"literal in the handlerM4competitor: fold the button to two values so both sides "agree"M5button stops reading the tableFIXthis branchDOM instrument (
jsdom, realindex.html+ the four real scripts,fetchstubbed and logged, driventhrough real nav → real row button → real
PATCH, reading#toast-wrap;expectdeclared per leg):unfixed tree 10/10 as declared with exactly the three axis legs red (
A2/A3/Z1, in both theen and zh packs), this branch 10/10 green, and the competitor fix (fold the button) 5/10 — the
instrument pins the direction. The "just delete the key" escape is also rejected: the assertions are on
rendered text, not key names.
Checklist
fix/…)