Skip to content

fix(ui): re-list outcome must come from the same entry as the action - #265

Merged
argszero merged 1 commit into
mainfrom
fix/sharing-toggle-outcome-names-the-transition
Sep 15, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/sharing-toggle-outcome-names-the-transition

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

A sharing key has three reachable statuses: PATCH /api/sharings/:id accepts on / paused / off
(off is a soft delete), 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), but the outcome toast picked it from the next status with a
two-valued ternary:

const next = s.status === "on" ? "paused" : "on";
toast(next === "paused" ? T("share.toggle.paused", ) : T("share.toggle.resumed", ), "success");

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 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, and 89963f3
(#94, "zero mock data") replaced the mock's three-branch toggle (which did toast relisted) with the
two-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 table SHARE_TOGGLE (status → { label, next, outcome }) so the
    button'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 gate
    the_sharing_toggle_outcome_comes_from_the_same_entry_as_its_action with four rules, each with its own
    tooth:
    1. every share.toggle.* literal in app.js lives inside the table (a literal outside = somebody
      picking an outcome by hand);
    2. the table's key set equals SHARE_STATUS's (both derived from source — no hand-written
      roster), label / outcome are unique per entry, next never points at its own status, and every
      referenced key exists in both packs;
    3. the handler (derived as the endpoint that sends a non-literal status to /api/sharings/) must not
      name a share.toggle.* key, must not branch on a status literal, and must share exactly one
      accessor with the button markup;
    4. entry count == the number of statuses the badges know.
      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; xlabel is not label), plus two cross-checks: the two key
      extractors 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 — it
    proves the outcome comes from the table, not that outcome/label are the right columns; that part is
    covered by the DOM probe below, since CI has no JS runner).
  • ui/index.html: cache-bust for app.js (?v=20260915-16).

No config / data-structure changes, so no example file needed updating.

Tests

  • cargo test passes — 295 passed (was 294; +1 new gate)
  • cargo fmt --check passes
  • New unit test added (the gate above, with its own synthetic-input self-proofs)

A/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):

leg declared actual
V0 unfixed tree RED · rule 1 RED — 5 share.toggle.* literals outside the table
M1 inline literal next to the button RED · rule 1 RED — 1 literal outside the table
M2 rename a table key (pausedpausing) RED · rule 2 RED — key set ≠ SHARE_STATUS
M2b fold two outcomes onto one key RED · rule 1b RED — outcome duplicated by paused / off
M2c next points at its own status RED · rule 1b RED — offoff
M3 handler branches on a status literal RED · rule 3 RED — "on" literal in the handler
M4 competitor: fold the button to two values so both sides "agree" RED · rule 1 RED — literals outside the table
M5 button stops reading the table RED · rule 3 RED — no shared accessor
FIX this branch GREEN GREEN

DOM instrument (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):
unfixed tree 10/10 as declared with exactly the three axis legs red (A2/A3/Z1, in both the
en 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

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal change

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.
@argszero
argszero force-pushed the fix/sharing-toggle-outcome-names-the-transition branch from 26e45fb to 32e9f18 Compare September 15, 2026 07:53
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (Committer, allow_self_merge)

Re-verified on the pushed head 32e9f18 (tree clean, md5 ui/js/app.js = f7f4008f146079ea5b680d0bff37c8eb).

What the fix does

The row button and the outcome toast now read the same entry of one transition table
SHARE_TOGGLE (status → { label, next, outcome }) through the single accessor
shareToggle(s.status). The two halves are now structurally incapable of diverging: there is no
place left where an outcome can be picked independently of the action that produced it.

What the gate proves — and what it does not

state_gate::the_sharing_toggle_outcome_comes_from_the_same_entry_as_its_action is a lexical
gate over app.js + the two language packs. It proves:

  • every share.toggle.* literal lives inside the table (rule 1);
  • the table's key set equals the badge table's, label/outcome are unique per entry, next
    never self-loops, and every referenced key exists in both packs (rules 1b/2 — both sides
    derived from source, no hand-written roster);
  • the handler that sends a non-literal status to /api/sharings/ neither names a
    share.toggle.* key, nor branches on a status literal, and shares exactly one accessor
    with the button markup (rule 3);
  • entry count == the number of statuses the badges know (rule 4).

It does not prove that the label / outcome columns hold the right words — a lexically
well-formed table whose off entry said label: "share.toggle.pause" would pass. That part is
covered by the DOM instrument, which asserts on rendered text in both packs. This boundary is
recorded in ui/README.md. CI has no JS runner, so the split is deliberate: the gate pins the
structure, the probe pins the values.

Evidence (re-run this round, on the pushed head)

Gate A/Btmp/c2153_gate_ab.py, in-place mutation of app.js only, byte-exact restore
(md5 verified), every leg declares its expectation and prints declared-vs-actual + PASS/FAIL as two
independent facts: 9/9 as declared

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 (pausedpausing) 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 — offoff
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 probetmp/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, exactly A2/A3/Z1 red 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 test 295 passed; cargo fmt --check clean; clippy only the pre-existing
    protocol.rs:662 warning. CI green on 32e9f18.

@argszero
argszero merged commit 6936973 into main Sep 15, 2026
1 check passed
@argszero
argszero deleted the fix/sharing-toggle-outcome-names-the-transition branch September 15, 2026 08:00
argszero added a commit that referenced this pull request Sep 21, 2026
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.
@argszero argszero mentioned this pull request Sep 21, 2026
12 tasks
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.
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