Skip to content

fix(i18n): stop the backend from inventing Chinese display labels in response data fields - #252

Merged
argszero merged 3 commits into
mainfrom
fix/backend-invented-display-labels
Sep 14, 2026
Merged

argszero merged 3 commits into
mainfrom
fix/backend-invented-display-labels

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The client translates the backend's error field (ui/js/api.js hands it to I18n.mapErr(), wordlist in ui/js/i18n.js) — #249 covered that path and its gate. Response data fields have no such path: the UI renders them raw. So a backend that invents a Chinese display label and puts it in a data field prints Chinese on the en interface, and cargo test stays green. This PR removes the two reachable cases and adds one contract per direction.

Reachable leaks (both measured, not read off the code):

backend origin render site trigger
src/routes/admin.rs — department bucket COALESCE(d.name, '(未分配)') barRow(d.name, …)#usage-dept any user with dept_id IS NULL + usage this month
src/gateway.rs — plan name derived from type (API(按量) …) #sf-plan options / share toast / share-table cell always — none of the 6 [[plans]] in config.example.toml set name

Both fire next to a localization mechanism that already exists on the same screen/payload — the member table on that very admin page uses T("common.unassigned"), and showPlanHint already maps pl.type through share.plan.* — so this is a missed call site, not a missing feature.

Related Issue

None — found by scanning, not reported.

Changes

  • src/routes/admin.rs: the no-department bucket returns '', the same shape as the neighbouring users[].dept_name (COALESCE(d.name, '')).
  • src/gateway.rs: /api/plans returns p.name verbatim (empty when config leaves it unset); docstring rewritten; existing test updated.
  • ui/js/app.js: planLabel(pl) — config's own name when set, else a wordlist key per type (share.planName.paygo|token|coding, 3 new keys, packs now 809). Used by the dropdown, the share toast and the share-table cell. The department bucket renders d.name || T("common.unassigned") (existing key, zero new ones).
  • ui/js/app.js: the Plan dropdown is rebuilt when its data source changes (dataset.plansSrc) instead of once (dataset.init), and the cascading fills read the source at call time. The view renders before loadSharing() resolves, so the fallback table always built the dropdown first and the one-shot guard kept it for good — the backend half was invisible on that control.
  • ui/index.html cache-bust; ui/README.md documents the rule and a same-axis item deliberately left out.
  • No config/data-structure change.

Tests

  • cargo test275 passed / 0 failed
  • cargo fmt --check — clean (clippy: only the pre-existing false collapsible_match at src/protocol.rs:662)
  • New tests:

i18n_pack::backend_data_fields_are_language_neutral — scans every src/**/*.rs, extracts Chinese literals delivered through a json! data field (key ≠ error), including through a local let binding (the plan-name fallback was written that way — without that layer the gate is green on the pre-change tree) and skipping nested json! and #[cfg(test)] modules. The result must be exactly the adjudicated exemption list — a new invented label turns it red, a stale exemption turns it red too. The roster carries a deliberate budget of one, because it is a sunset list rather than a registry: the tempting "keep inventing, register the label" fix has to edit a second place to pass.

i18n_pack::backend_neutral_data_labels_are_localized_in_the_client — both render sites must have the localized fallback (a neutral backend without one just yields a blank label), and the dropdown must be rebuilt from source evidence rather than a one-shot flag.

routes::tests::usage_department_bucket_without_a_department_is_language_neutral — the runtime contract: with a dept_id IS NULL user who has usage this month, the bucket name carries no CJK, and a real department name still passes through verbatim.

Scanner geometry helpers (enclosing_fn_span, local_binding_rhs, cjk_literals, statement_containing) have their own self-proof test that injects labels in several literal forms and asserts the extractor's output contains the marker — an extractor that silently finds nothing is the failure mode this guards.

A/B

Rust, full cargo test per leg, mutated in place and restored against HEAD (md5 + git diff --stat + diff checks):

leg red set
L1_gateway (plan-name fallback restored) {backend_data_fields_are_language_neutral}
L2_admin (SQL bucket restored) {usage_department_bucket_without_a_department_is_language_neutral}
L3_client (client fallback removed) {backend_neutral_data_labels_are_localized_in_the_client} + 2 key-count gates (collateral)
L4_roster_escape (competing fix: keep the invented name and register it in the roster) {backend_data_fields_are_language_neutral}
working tree {}

JS (tmp/c2133_probe.js; jsdom, real index.html + the 4 real scripts, only fetch stubbed, atp_lang=en forced; each check compared against its expectation, so the pre-change leg's demonstration checks are not mistaken for acceptance failures; every leg generated by string substitution from the working tree):

leg mismatched checks
working tree {} — 17/17
pre-change tree (client + payloads from origin/main) {B2b,D1,D2,E2,F1,F2}
render sites read pl.name raw {A2b,D2}
dropdown keeps the one-shot guard {F1,F2}
dropdown reads a source snapshot taken at first render {B2b,D2}
no-department bucket loses its localized fallback {D1,E2}

F1/F2 (the provider list must follow the live plan source) are what make the rebuild half observable at all — without them that leg reads green, which is how the one-shot guard survived the first pass.

Checklist

  • Branch named fix/…
  • Conventional Commits (fix(i18n): …, test(i18n): …)
  • Single responsibility, minimal change — one axis: invented display labels in response data fields

Deliberately not bundled

ui/js/data.js carries its own PLANS[].name display labels (API(按量), Kimi Code 会员…), which the client shows when /api/plans fails. Different producer (a client data table, not a response field), and it needs a brand-vs-generic ruling first. provLabel() right beside it is the pattern to copy (I18n.lang === "zh" gates the label table, en gets the id). Recorded in ui/README.md and in the project ledger.

…response data fields

The client localizes the `error` field (`api.js` hands it to `I18n.mapErr()`,
wordlist in `ui/js/i18n.js` — see #249). Response **data** fields have no such
path: the UI renders them raw. So a backend that invents a Chinese label and
puts it in a data field prints Chinese on the `en` interface, and `cargo test`
stays green. Two reachable cases:

1. `GET /api/admin/usage` — the department bucket was
   `COALESCE(d.name, '(未分配)')` and `app.js` prints it through `barRow()`
   into `#usage-dept`. Any user with `dept_id IS NULL` and usage this month puts
   Chinese on an English screen — while the member table on the *same page* has
   used `T("common.unassigned")` all along.
2. `GET /api/plans` — when config leaves `name` unset the backend derived a
   display name from `type` (`API(按量)` / `Token Plan` / `Coding Plan`).
   Neither `config.toml` nor `config.example.toml` sets `name` for any
   `[[plans]]`, so it always fired, into `#sf-plan` and the share toast.

Fix — the backend returns data or a language-neutral marker, the client owns the
wording:

- the no-department bucket is `''` (same shape as the neighbouring
  `users[].dept_name`), and the client renders `d.name || T("common.unassigned")`
  — an existing key, zero new ones;
- `/api/plans` returns `p.name` verbatim (empty when unset), and the client gets
  `planLabel(pl)`: config's own name when present, else a new key per type
  (`share.planName.paygo|token|coding`), reused by both render sites.

Three CI contracts, one per direction:

- `i18n_pack::backend_data_fields_are_language_neutral` — scans every
  `src/**/*.rs` and extracts Chinese literals delivered through a `json!` data
  field (key ≠ `error`), including through a local `let` binding (the plan-name
  fallback was written that way; without that layer the gate is green on the
  pre-change tree). The result must be *exactly* the adjudicated exemption list
  — a new invented label turns it red, and a stale exemption turns it red too.
  Its scope is stated in the doc comment: values manufactured by SQL
  (`COALESCE(..., '中文')`) are covered by the runtime contract below instead.
- `i18n_pack::backend_neutral_data_labels_are_localized_in_the_client` — both
  render sites must have the localized fallback, otherwise a neutral backend
  just yields a blank label.
- `routes::tests::usage_department_bucket_without_a_department_is_language_neutral`
  — the runtime contract: with a `dept_id IS NULL` user who has usage this month,
  the bucket name carries no CJK, and a real department name still passes
  through verbatim.

The one exemption (`routes::mod.rs` registration's `unwrap_or("用户")`) is a
*user-data* default — the same kind of value as an account name — and is
unreachable (`split('@').next()` is always `Some`); it is not an invented
display label.
…response data fields

The client localizes the `error` field (`api.js` hands it to `I18n.mapErr()`,
wordlist in `ui/js/i18n.js` — see #249). Response **data** fields have no such
path: the UI renders them raw. So a backend that invents a Chinese label and
puts it in a data field prints Chinese on the `en` interface, and `cargo test`
stays green. Two reachable cases:

1. `GET /api/admin/usage` — the department bucket was
   `COALESCE(d.name, '(未分配)')` and `app.js` prints it through `barRow()`
   into `#usage-dept`. Any user with `dept_id IS NULL` and usage this month puts
   Chinese on an English screen — while the member table on the *same page* has
   used `T("common.unassigned")` all along.
2. `GET /api/plans` — when config leaves `name` unset the backend derived a
   display name from `type` (`API(按量)` / `Token Plan` / `Coding Plan`).
   Neither `config.toml` nor `config.example.toml` sets `name` for any of the
   `[[plans]]`, so it always fired, into `#sf-plan` and the share toast.

Fix — the backend returns data or a language-neutral marker, the client owns the
wording:

- the no-department bucket is `''` (same shape as the neighbouring
  `users[].dept_name`), and the client renders `d.name || T("common.unassigned")`
  — an existing key, zero new ones;
- `/api/plans` returns `p.name` verbatim (empty when unset), and the client gets
  `planLabel(pl)`: config's own name when present, else a new key per type
  (`share.planName.paygo|token|coding`), reused by both render sites;
- the plan dropdown is now rebuilt when its **data source** changes
  (`dataset.plansSrc`) instead of once (`dataset.init`). The view renders before
  `loadSharing()` resolves, so the fallback table `D.PLANS` always built the
  dropdown first and the one-shot guard then kept it forever — the backend fix
  was invisible on that control. The cascading fills read the source at call
  time, so the once-registered listeners cannot pin the old table either.

Three CI contracts, one per direction:

- `i18n_pack::backend_data_fields_are_language_neutral` — scans every
  `src/**/*.rs` and extracts Chinese literals delivered through a `json!` data
  field (key ≠ `error`), including through a local `let` binding (the plan-name
  fallback was written that way; without that layer the gate is green on the
  pre-change tree). The result must be *exactly* the adjudicated exemption list
  — a new invented label turns it red, a stale exemption turns it red too, and
  the list has a budget of one, because it is a sunset list rather than a
  registry (the tempting "keep inventing, register the label" fix has to edit a
  second place to pass). Scope is stated in its doc comment: values manufactured
  by SQL (`COALESCE(..., '中文')`) are covered by the runtime contract instead.
- `i18n_pack::backend_neutral_data_labels_are_localized_in_the_client` — both
  render sites must have the localized fallback (otherwise a neutral backend
  just yields a blank label), and the dropdown must be rebuilt from source
  evidence rather than a one-shot flag.
- `routes::tests::usage_department_bucket_without_a_department_is_language_neutral`
  — the runtime contract: with a `dept_id IS NULL` user who has usage this month,
  the bucket name carries no CJK, and a real department name still passes
  through verbatim.

The one exemption (`routes::mod.rs` registration's `unwrap_or("用户")`) is a
*user-data* default — the same kind of value as an account name — and is
unreachable (`split('@').next()` is always `Some`); it is not an invented
display label.

A/B — the JS probe (`tmp/c2133_probe.js`; jsdom, real `index.html` + 4 real scripts,
only `fetch` stubbed, `atp_lang=en` forced, every check compared against its **expectation**
so the pre-change leg's demonstration checks are not confused with acceptance failures):

| leg | mismatched checks |
|-----|-------------------|
| working tree | `{}` — 17/17 |
| pre-change tree (client + payloads from `origin/main`) | `{B2b,D1,D2,E2,F1,F2}` |
| render sites read `pl.name` raw (backend neutral, no client label) | `{A2b,D2}` |
| dropdown keeps the one-shot guard (no rebuild) | `{F1,F2}` |
| dropdown reads a source snapshot taken at first render | `{B2b,D2}` |
| no-department bucket loses its localized fallback | `{D1,E2}` |

Each leg is generated by string substitution from the working tree, so the red sets are
reproducible; `F1`/`F2` (the provider list must follow the live plan source) are what make
the rebuild half observable at all — without them that leg reads green, which is how the
one-shot guard survived the first pass.

Rust side, `cargo test` per leg (mutated in place, restored against `HEAD` with md5 +
`git diff --stat` + `diff` checks) — see the PR body for the recorded sets.

Same axis, deliberately not bundled (recorded in `ui/README.md`): `ui/js/data.js`
carries its own `PLANS[].name` display labels (`API(按量)`, `Kimi Code 会员`…),
which the client shows when `/api/plans` fails. Different producer (a client data
table, not a response field) and it needs a brand-vs-generic ruling first —
`provLabel()` right next to it is the pattern to copy (`I18n.lang === "zh"` gates
the label table; `en` gets the id).
@argszero
argszero merged commit 91a2da1 into main Sep 14, 2026
1 check passed
@argszero
argszero deleted the fix/backend-invented-display-labels branch September 14, 2026 23:42
@argszero argszero mentioned this pull request Sep 15, 2026
10 tasks
argszero added a commit that referenced this pull request Sep 15, 2026
Ships the 18 PRs merged since v0.7.24 (#242-#259). Schema 14 -> 15 (two
covering indexes, applied at startup). No config change, so no deployment-side
config.toml edit is needed.

Two themes:

Perf on the NFS dev database
- #259: stop mapping the db (PRAGMA mmap_size 64MB -> 0) and stop a real write
  per request (dao::touch_api_key gains a 60s guard). Measured on the live dev
  db: mmap=64MB 1.7-3.1s per COUNT / 250 MiB read vs mmap=0 ~10.5ms / 80 KiB;
  mmap=0 alone still leaves ~1.2s behind any write, so the pair is required.
- #242: codify the two emergency indexes in a v15 migration and gate the
  conditional joins at the plan level.
- #243: read the sharing page's earn total from one batched aggregate.

Frontend: display must equal what it filters on, and one fact, one source
- #250 one writer for the transaction cache; #251 clear every session slot at
  the identity boundary and give the wallet view a loader; #253 one shared
  writer for the wallet/dashboard month-changes; #254 boot loads only the
  destination view; #255 a model row's identity is the model, not its index;
  #256 the marketplace source follows the session, not whether data arrived;
  #257 the sidebar advertises only digits that work; #258 the admin
  total-balance card sums the gift amount its caption names.

i18n
- #249 every backend error reaches the wordlist, and the comment stripper stops
  mangling UTF-8; #252 the backend stops inventing Chinese display labels in
  response data fields.

Forms and robustness
- #244 a non-auth boot failure no longer looks like being logged out; #245 a
  credential 401 is no longer read as a session expiry; #246 wire timestamps
  reach the renderer unsliced; #247 inline cards submit from every field; #248
  a market row's availability label comes from that row.

- Cargo.toml / Cargo.lock: 0.7.24 -> 0.7.25.
- CHANGELOG.md: v0.7.25 entry.
- ui/index.html: cache-bust left as-is; the UI PRs in this release already
  advanced it past the value deployed with v0.7.24 (app.js 20260915-13,
  i18n.js 20260915-3).

cargo test 288 passed; cargo fmt --check clean; clippy unchanged.
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