fix(ui): identify a model row by the model, not by its array position - #255
Merged
Merged
Conversation
…osition
`modelsToView()` used to mint a per-row identity from the row's array index
(`id: i`). That index is used as the model's identity in places that outlive
the render that produced it: it is persisted (`markRecentUsed()` →
`localStorage["atp-recent-models"]`) and resolved again in a *different* array
(`D.MARKET` for a guest session) or in a *different catalog order*
(`/api/models` is `ORDER BY provider, model`).
Because the two spaces overlap numerically, the failure is silent:
- live index 5 (`xai/grok-4.6`) → `D.MARKET` id 5 = `google/gemini-3.1-pro`
- live index 0 (`anthropic/claude-opus-5`) → the guest table is 1-based, so
the entry disappears instead of pointing at something
- an admin adds a model that sorts earlier → every index shifts, the chip
names `moonshot/kimi-k3`, and the chat opened from that chip is kimi-k3
The identity becomes `modelKey(m)` = `provider/model`, everywhere: the row
object no longer carries a position, the three `data-*` carriers
(`data-mk-expand` / `data-use-model` / `data-recent-model`) are rendered from
`modelKey(m)`, the click sites pass the identity string through unchanged, and
the recent-use store accepts identity strings only (a stored *index* cannot be
honestly resolved back to a model, so it is dropped once — deliberately).
Tests: `src/state_gate.rs::the_model_row_identity_is_the_model_not_its_position`
(4 rules, each with its own tooth) plus extractor self-checks; the instruments
`tmp/c2138_probe.js` (17 checks) and its A/B legs.
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.
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
modelsToView()(ui/js/app.js) adapted/api/modelsrows for the UI and minted a per-row identity from the row's array index (id: i). That index was used as the model's identity in two places that outlive the render which produced it:markRecentUsed(id)writes it tolocalStorage["atp-recent-models"]("recently used" chips);renderRecent()/openChat()/consumeModel()look it up inLive.models ? modelsToView(Live.models) : D.MARKET, and for a guest sessionD.MARKET(ui/js/data.js) is a different table (7 rows, ids1..7, different order and length) that merely looks like the same space; the backend also serves/api/modelsORDER BY provider, model, so adding/removing/renaming any model shifts every index after it.Because the two spaces overlap numerically, the failure is silent and looks like real data:
xai/grok-4.6(index 5) → log out → guest marketgoogle/gemini-3.1-pro— a model this user never usedanthropic/claude-opus-5(index 0)moonshot/kimi-k3(index 5 after the shift)kimi-k3(openChatresolves the same index)This is a defect, not a trade-off: the same file already identifies models by name elsewhere (
Live.models.find((x) => x.model === model), the shares form's<option value="m.model">), and the host has already ruled on the same family once (session caches must be keyed by identity, not by the slot they happen to sit in).Related Issue
(no linked issue — this change was driven by a jsdom instrument plus an A/B; the evidence is below)
Changes
ui/js/app.js: newmodelKey(m)=provider + "/" + model;modelsToView()no longer emits a position (the.map(callback takes one parameter, the row object has noid); the three identity carriers (data-mk-expand,data-use-model,data-recent-model) are rendered fromesc(modelKey(m)); the click sites pass the identity string through unchanged (no moreNumber(...));getRecentKeys()accepts identity strings only — a value stored by an older version is an index, which cannot be honestly resolved back to a model, so it is dropped once (deliberate).src/state_gate.rs: new static gatethe_model_row_identity_is_the_model_not_its_position, four rules each with its own tooth — ① the.map(callback takes one parameter and the row object declares noidfield; ② the threedata-*carriers are produced bymodelKey(and no click site re-wraps them inNumber(; ③modelKeyis defined exactly once, mentionsproviderandmodel, neverid; ④ everymarkRecentUsed(...)call site writes amodelKey(...)expression — plus extractor self-checks (the_model_identity_extractors_have_teeth, with synthetic and negative controls).ui/README.md: new convention section ("cross-render / cross-session / cross-array model identity goes throughmodelKey(); an array index is only meaningful inside the array that produced it"), including the discriminator note thatrenders_attrmust require bothattr=and "this line is not a selector query" (the first version of the gate read the consumer'squerySelector('[data-use-model="' + id + '"]')as a render site).ui/index.html: cache-bustapp.js?v=20260915-10.Measured (jsdom boots the real
ui/index.html+ all four real scripts; onlyfetchis stubbed and logged)tmp/c2138_probe.js— 17 checks,expectdeclared per check. Fixtures: two/api/modelspayloads, both orderedprovider, model(W1six rows;W2=W1plusanthropic/gpt-9, so every index from 1 shifts by one).Before (
6be548a,ui/js/app.jsmd57171e11f…) — exactly the 5 axis legs red:chips=["gemini-3.1-pro"] store=[5]— the chip names a model that was never usedchips=["kimi-k3"] store=[5]chat title="Use kimi-k3"— the display error becomes an action errorstore=[5]— the persisted identity is a positionstore=[0] chips=[]— the entry is lost against the 1-based guest tableAfter — 17/17,
store=["xai/grok-4.6"], chat titleUse grok-4.6.A competing fix (hand-aligning
D.MARKET's ids to today's catalog order,app.jsuntouched) satisfies the two guest faces (B1/E1 turn green) but is rejected by the drift and mechanism legs (D1/D2/C2 stay red) — 15/17 as declared.Why this needs a static gate
The instrument can only prove "the screen no longer shows a foreign payload". It cannot distinguish "the identity is now the model" from "somebody hand-aligned the two tables once". The gate pins the shape, and its A/B (in-place mutation, byte-for-byte restore checked by
md5) shows each rule failing on its own leg:6be548a)id: iback in the row objectid)data-use-modelfromm.modelmodelKey()consumeModel(Number(b.dataset.useModel))modelKeydropsprovidermodelKeyappendsm.idid)m.modelinstead ofmodelKey(m)Tests
cargo test— 279 → 281 passedcargo fmt --checkcleancargo clippy— no new warnings (only the pre-existingsrc/protocol.rs:662false positive)Checklist
fix/…)