Skip to content

fix(i18n): name every written transaction type in the type prose, and gate it - #291

Merged
argszero merged 1 commit into
mainfrom
fix/tx-type-prose-names-every-written-type
Sep 24, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-type-prose-names-every-written-type

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

Three hand-written prose enumerations of the transaction types each omitted expire, and the
English ones also described the types with derived nouns instead of the labels the same screen
already uses for them:

key where it shows before (zh) before (en)
view.transactions.sub transactions view subtitle 涵盖消费、收益、充值、提现、赠送 Consumption, earnings, top-ups, withdrawals, gifts
dash.trades.sub dashboard "trades" card subtitle 含充值 / 消费 / 收益 / 赠送 top-up / consume / earn / gift
wallet.hint.suffix wallet footnote ——涵盖消费、收益、充值、提现、赠送 — consumption, earnings, top-ups, withdrawals, gifts

The fact they are all copies of lives in two places the app already owns:

  • what the app accepts — src/routes/wallet.rs:27
    pub const TX_FILTER_TYPES: [&str; 6] = ["consume", "earn", "topup", "gift", "expire", "withdraw"]
  • what the app writes — seven production INSERT INTO transactions statements
    (billing.rs ×2, gift.rs ×2, routes/{admin,ops,raise}.rs ×1 each) which put exactly
    {consume, earn, expire, gift, topup} into the type column. withdraw has no writer.

So an expire row is a real ledger line — gift expiry really debits the balance (that is the
C2050/C2051 change, gift.rs::expire_past_gifts), and it renders in the transactions table's type
column and in the CSV export — while three places on screen said such a row does not exist. One fact,
three carriers, the same item dropped in all three.

Provenance is drift, not a trade-off: the three strings were written before expire became an
emitted type, and nothing tied the enumeration back to the set it enumerates. The English ones had a
second drift of their own — Consumption/earnings are not the tx.type.consume/tx.type.earn
labels the very same table column prints, so the prose and the column named one type two ways.

Related Issue

None — this came out of an internal audit of the transaction-type enumerations, not from an open
issue. The linked-issue field is expected to stay empty by design; the PR is based on the default
branch (main).

Changes

  • ui/js/i18n.js — six values (view.transactions.sub, dash.trades.sub, wallet.hint.suffix ×
    zh/en): name expire, and use the packs' own tx.type.<t> label vocabulary in English
    (Consume, earn, top-up, withdraw, gift, expired) instead of derived nouns. Zero new i18n keys,
    zero key removals
    (both packs stay at 788 keys).
  • ui/index.html — the two static data-i18n fallbacks (:414 wallet.hint.suffix,
    :424 view.transactions.sub) follow the packs, plus the js/i18n.js?v= cache-bust.
  • ui/README.md — the page-list entry for the transactions view, plus a contract line for the new gate
    (including its honest scope, below).
  • src/state_gate.rs — new lane the_transaction_type_prose_names_every_type_the_writers_write,
    entirely test-only.

The gate

Every expected value is derived from the artifacts — there is no hand-written type list:

  • R1 precondition (a set assertion over an empty set is vacuously green): ≥20 source files
    scanned, ≥2 production write sites, ≥2 written types, ≥2 roster keys, and both packs' key sets
    equal
    with ≥100 keys each.
  • R2 naming: the "roster" is the pack keys that name more than half of the written types
    (union over both packs); every roster key × every language must name all named written types.
    The ruler is that pack's own tx.type.<t> value (case-insensitive literal substring) — so
    Consumption does not count as naming consume, and expiry cannot stand in for Expired.
  • R3 shape: the two packs' key sets must be equal, and tx.type.<t> must be non-empty in both
    packs (a label on one side only leaves that side's prose nothing to name).
  • R4 sandwich: written ⊆ accepted ⊆ labeled. The written set comes from the literal at the
    type column of each production INSERT INTO transactions — column-aware, so an unrelated
    literal ('成功', 'm') is never mistaken for a type; the accepted set comes from
    TX_FILTER_TYPES, the array whose own docs call itself the single source for adding a type. This
    catches both ways a new type gets missed: written-but-not-accepted (unfilterable in production) and
    accepted-but-unlabeled (the table prints a bare key).

Companion tests: an extractor self-test on synthetic input (an anchor inside a full-line comment, one
inside the #[cfg(test)] region, and a non-type-column literal must all be excluded, and the
corpus-boundary predicate must keep *_gate.rs / i18n_pack.rs out of the corpus while keeping real
sources in); a #[should_panic] test that a writer binding the type column to a parameter (?1) is
loud rather than silently skipped; and a teeth test where each rule has a variant that flips only
that rule.

Scope, stated honestly (also in ui/README.md): the gate is lexical — it proves every roster
prose names the five types, not that the screen shows that sentence (that half is the jsdom probe).
Three declared blind spots: (①) "more than half" is a declaration, so a key naming ≤ half stays out
of the roster — tx.summary.net.sub ("收益 − 消费") is exactly that, it names a difference not an
enumeration; (②) the roster is derived, so deleting an enumeration or its [data-i18n] binding does
not turn it red — that is making a promise disappear, and it is covered by the probe and by
i18n_pack::every_pack_key_reaches_a_consumer; (③) a writer must use a literal — a future
parameter-bound writer turns the gate red on the spot rather than being silently skipped.

Scope decision: "⊇ the written set", not "all six accepted values"

withdraw is accepted and has a selector option, but no writer — no withdrawal row can appear in
the transactions table. Listing it in prose would assert something that cannot happen, while missing
expire asserted something that did. So the gate requires ⊇ {consume, earn, expire, gift, topup},
and dash.trades.sub (which names five and omits 提现) is left as it is: it omits nothing that can be
written. The gate is forward-looking: the teeth test includes a variant that adds a withdraw
writer and shows the prose then turns red, so the day withdrawal ships, the enumeration must follow.

Two repairs made while landing this

The lane's two self-proof tests had never been executed — earlier rounds type-checked the fragment
through rustc and ran clippy over it, and neither reads a test's expectation at run time. Running
cargo test for the first time exposed two defects, both confined to the self-proof layer (no rule
changed, so the gate's semantics — and therefore anything derived from it — are unaffected):

  • the pre-fix red-set declaration was missing a backtick in its label format
    ("{lang}{key}" instead of `` "{lang}{key}`" ``, the form the same file uses for its other two
    red-set declarations), so the assertion compared a correct measurement against a malformed
    expectation;
  • the extractor fixture placed a production statement after #[cfg(test)] (truncated away — it
    looked like the extractor missing a write site when the fixture was simply not shaped like real Rust),
    and asserted the corpus boundary (*_gate.rs) through r99_written, which does not do the
    excluding — that lives in the disk walk. The boundary claim is now asserted against a named
    predicate, r99_is_corpus_file, which the walk itself calls, with both directions covered (real
    sources in, state_gate.rs / body_limit_gate.rs / i18n_pack.rs out).

Tests

  • cargo test — 373 passed / 0 failed (baseline 369; the lane adds four)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — no new diagnostics from this change. On this
    machine the pinned toolchain (1.95.0, the one with a working rustc) reports
    clippy::collapsible_match at src/protocol.rs:662, which is pre-existing: a pristine
    git archive HEAD tree reports the same single error, protocol.rs is untouched by this PR, and
    CI on main at the parent commit is green under CI's stable toolchain (which is the command
    ci.yml actually spells).
  • New tests added (the lane's four tests are the gate)
  • Two independent instruments:
    * compile gate A/B — the landed tree must be green first, then six variants whose red sets
    are asserted against the declared ones (pre-fix = all six (lang, key); half-fixed = the three
    zh ones; new writer = dash.trades.sub both packs; unmatched writer = R4 only; label dropped
    from both packs = R4 only; one-sided empty label = R3 + R4), plus the two declared blind spots
    asserted silent;
    * jsdom probe — 5 variants × 12 legs, all as declared: landed 12/12 green; base red on
    exactly the six A1/A2/A3 × zh/en; m_half exactly the three zh; m_en_derived (derived
    nouns + expired appended — the near miss that adds the missing type but not the missing
    word) red on A1_en/A3_en; m_html_stale green by design. The probe
    derives the written, accepted and roster sets from the artifacts and reads the two
    [data-i18n] subtitles from the real DOM under a real boot, in each language's own window.
    * The asymmetry is deliberate and stated in ui/README.md: the gate refuses the vocabulary-drift
    fix that the screen would accept, while the probe's landed leg is what proves the shipped
    bytes; neither instrument is sufficient alone.
  • One limit stated for the record: the two ui/index.html static fallbacks are not covered by
    either instrument — the probe boots the app and applyStatic() replaces the fallback text, so
    the DOM never shows a stale value; and i18n_pack::every_static_i18n_attribute_resolves checks
    that the attribute's key resolves, not that the fallback text matches the pack. They were
    updated for consistency only; that drift axis is a known, separately decided one.

Checklist

  • Branch named per convention (fix/…)
  • Commit message in Conventional Commits format
  • Single responsibility, minimal blast radius (no production Rust behavior touched; the Rust
    change is test-only)
  • Anything that changes config/data structures also updated in the sample files — N/A (no
    config or schema change)

… gate it

Three prose enumerations of the transaction types were hand-copied from
`TX_FILTER_TYPES` and never followed `expire` when gift expiry became a real
ledger row (C2050/C2051): the transactions page subtitle, the dashboard
"trades" subtitle and the wallet footnote all said the table carries
consume / earn / topup / gift / withdraw while the table itself printed an
"expired" row. The English values were worse than stale: they used derived
nouns ("Consumption, earnings, top-ups, withdrawals, gifts") rather than the
label the same screen uses for that type.

- name `expired` in all three keys, both packs, and switch the English values
  to the `tx.type.*` label words so the prose and the table agree word for word;
- add `state_gate::the_transaction_type_prose_names_every_type_the_writers_write`
  -- the roster of prose keys is derived (any pack value naming more than half
  the written types must name all of them), the written set is derived from the
  `type` column of every production `INSERT INTO transactions`, the accepted set
  from `TX_FILTER_TYPES`, and the labels from the packs themselves. No type name
  and no key name is written by hand. The sandwich written ⊆ accepted ⊆ labelled
  catches both kinds of "one place missed" when a new type lands;
- declare the gate's three blind spots in `ui/README.md` rather than leaving them
  implicit: the "more than half" threshold, the roster being derived (deleting a
  commitment silences it -- that half belongs to the reachability gate and the
  behaviour probe), and the requirement that write sites be literals.

The prose decision is "⊇ the written set": `withdraw` is accepted but has no
writer, so naming it is allowed and omitting it is fine; omitting a written type
is the defect. A teeth variant adds a `withdraw` writer and the prose goes red
the day that changes.
@argszero
argszero merged commit 1f1bd68 into main Sep 24, 2026
1 check passed
@argszero
argszero deleted the fix/tx-type-prose-names-every-written-type branch September 24, 2026 14:07
@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