Skip to content

fix(plugin-audit): localize select option labels in the tracked-change activity summary (#7289) - #7389

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7289-select-option-label-localization
Aug 10, 2026
Merged

fix(plugin-audit): localize select option labels in the tracked-change activity summary (#7289)#7389
os-zhuang merged 1 commit into
mainfrom
claude/issue-7289-select-option-label-localization

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #7289

displayFieldValue rendered a select/picklist value by scanning field.options[] and returning the matching option's authored label. field.options comes from engine.getSchema(name) — locale-independent metadata — while the shipped bundles carry those same labels under objects.[object].fields.[field].options.[value] (translations/zh-CN.objects.generated.ts: sys_audit_log.fields.action.options.create = "创建"). Nothing on this path read them, so after #7230 localized the field label a zh-CN workspace got

阶段: Proposal → Closed Won

— a half-localized string at the bottom of a fully-localized page. sys_activity.summary is composed at write time and shipped verbatim to every consumer at once, so this is one string on the record discussion feed, console home activity, the header inbox, the Setup sys_activity list and mobile/REST/SDUI simultaneously.

Anchors re-derived at the branch point (3c03725d2), because the card's are stale

The card recorded its anchors at a768f81a1 (pre-#7230). The file has moved four times in a day; all of them had moved, and one of the card's structural claims was no longer true.

anchor card (a768f81a1) at 3c03725d2
displayFieldValue :308 :370 — and it already took a third parameter (titlesFor, #7230)
the option branch :310-319 :376-386
renderTrackedChangeSummary :441 (caller 1)
renderMilestoneSummary :575 (caller 2, added by #7290)
the two call sites :872 (one) :1180-1198 (milestone) / :1204-1215 (tracked change)

The triage comment's implementer note is stale and was not implemented against. It said "matchMilestone's call site needs the two new arguments or an explicit opt-out". #7290 did not thread arguments through matchMilestone — it split it: matchMilestone (sync detection, no longer takes fields) → planMilestoneTokenReadsrenderMilestoneSummary. matchMilestone never calls displayFieldValue at all now, so there was nothing to opt out at that site. The PM's claim comment flagged this in advance and it is confirmed here. The premise itself is intact: the option branch at :376 still returned String(ol) from the authored label, with no translator anywhere on the path.

The judgment call: the two callers get different answers

This is the substance of the card, so it is a ruling rather than a side effect. displayFieldValue has two callers and they are not symmetric.

renderTrackedChangeSummary — localized. Its frame is fully localized already: the verb template and the object label (ADR-0053 / framework#3039), and since #7230 the field label. The authored option value was the single remaining untranslated token inside it. Translating it makes the string uniformly localized, which is exactly the reported defect closing.

renderMilestoneSummary — NOT localized, by construction. It passes no option resolver, so a select token there renders its authored label byte-for-byte as before. The reasoning, since "the shared helper changed" is precisely what must not decide this:

If the maintainer wants the milestone branch localized after all, it is a one-argument addition at a single call site — but it should be an explicit contract change, not an inherited default. This PR keeps that decision open and reversible.

Shape

displayFieldValue gains a fourth optional parameter, optionLabelFor, mirroring exactly the idiom titlesFor established: an already-resolved lookup, never I/O, so the function stays synchronous, and a caller with nothing to resolve passes nothing and keeps today's behaviour exactly. renderTrackedChangeSummary builds it from the object name, the field key and the translator it already holds; the closure is only allocated for a field that actually declares options, and is shared by the two calls (old side, new side).

The lookup is consulted only where a declared option matched, so a value matching no option still renders String(value). Bundles are generated from the declared options (os i18n extract), so a bundle key with no matching declared option cannot arise in shipped data — and a miss falls straight back to the authored label. Restore-invariant in the same sense as the reference branch: it can only replace an authored label with that label's translation, never the reverse.

Read cost — zero, on every write shape

Option-label localization is a bundle lookup, not I/O. #6656 / PR #6977 retired captureBefore's redundant pre-image read from this exact path (2 → 1 per single-id write, 3 → 0 per predicate write); #7291 kept it and #7333 re-asserted it. Re-asserted here rather than assumed — all three prior suites run unmodified and green (audit-bound-previous.test.ts, audit-lookup-summary.test.ts, audit-milestone-summary.test.ts), and this PR's own file measures added reads directly:

write shape (locale ON) added reads
create holding references 0
tracked option-only change, on a row that holds references 0
fired milestone with one reference token 1#7290's profile, unchanged

The zero-read guards run against deal_2, a row that already holds owner and account_id, for the reason #7291 measured the hard way: a zero-read guard on a reference-free row is green for the wrong reason. That this one is not was measured, not assumed — see M5 below.

Tests

New file packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts — 13 cases on a real ObjectQL engine with the copy-returning counting driver.

The locale fixture is a real createMemoryI18n bundle. Section 4 goes further and resolves the code's own key template against the shipped generated bundle — i18n.t('objects.sys_audit_log.fields.action.options.create', 'zh-CN') — so the fix is pinned to shipped data rather than to a fixture that agrees with itself, and the miss-returns-the-key-verbatim contract translateWith depends on is pinned beside it.

Fallbacks are covered per value, not per field: stage.options.on_hold and priority.options.p2 are deliberately absent from the bundle, so 阶段: 提案中 → On Hold pins that a present sibling key does not carry an absent one. Two no-translator controls (no i18n service; no locale) pin the status quo.

One fixture choice is worth naming: the "value matching no declared option" case uses a text field carrying an advisory options array, not a select. That is forced, and measured — record-validator enforces "select / multiselect: value must appear in options" and refuses the write, so a select cannot express that state through a real engine at all. displayFieldValue's option branch keys on field.options being an array, not on field.type, so the text field reaches exactly the same branch.

pnpm --filter @objectstack/plugin-audit test       Test Files 12 passed (12)   Tests 201 passed (201)
pnpm --filter @objectstack/plugin-audit typecheck  tsc --noEmit — clean (exit 0)
pnpm --filter @objectstack/plugin-audit build      — success
npx eslint packages/plugins/plugin-audit --no-inline-config  — clean (exit 0)

Family gates, all green locally: check:nul-bytes, check:durability-log-level, check:engine-double-contract, check:error-code-casing, check:route-envelope, check:wildcard-fallthrough, check:empty-changeset, check:adr-0087-registration, check:changeset-gate-self-tests, check:type-check-coverage.

Reverse verification — direction predicted in writing before the first mutation

Predictions were written to a file and the tree committed before any mutation ran. Mutations were taken out with git checkout HEAD -- ..., never git stash.

# mutation predicted measured
M1 drop the translate limb (the pre-#7289 code) red: every case asserting a zh-CN option label as predicted — 7 red / 194 green, and they emit the reported string verbatim: expected '阶段: Proposal → Closed Won' to be '阶段: 提案中 → 已赢单'
M2 the rejected design — the milestone branch localizes too red: the with-locale milestone case only; the pre-existing seam case cannot bite as predicted — exactly 1 red, and the pre-existing seam case stayed green
M3 right idea, wrong bundle key shape (read .label, not .options.[value]) red: the zh-CN option cases 11 red — wider than predicted, see below
M4 let the lookup escape the matched-option branch red: the String(value) case only as predicted — exactly 1 red
M5 resolve titles from the written row instead of the diff (#7291's mutation D — the #6656 / #6977 regression) red: the zero-read case, on its count assertion; the create-count case cannot bite as predictedexpected 1 to be +0, and 6 further reds inside #7291's and #7290's own suites

M2 is the mutation that matters, because it is the ruling. Applying the rejected design — threading objectName + translate into renderMilestoneSummary — turns exactly one case red, 'renders the authored option label inside the untranslated author sentence', which is the guard for the decision argued above. It is reversible in one argument, and nothing else in the suite notices.

And it confirmed the warning written into the test file before it ran. The pre-existing seam case #7333 left behind for this card — 'leaves select option-label rendering exactly as it was (#7289 owns that surface)' in audit-milestone-summary.test.ts — boots with no locale, so translate returns undefined either way and it stayed green under M2. It cannot bite on this ruling. The with-locale milestone guard added here is the one that can, which is why it was added rather than relying on the existing case.

M3 was wider than predicted, and the mutation is at fault, not the code. I wrote it as translate(...fields.[key].label) ?? String(optionValue), which changes two things — the key shape and the miss fallback — so it also reddened the two no-translator controls (which then fell to the raw value rather than the authored label) and two cases in audit-writers.test.ts. As a key-shape mutation it is impure; its red set is a superset of the honest one. It still demonstrates what it was written for: a wrong key shape does not pass silently.

Disclosed rather than counted: three cases here went red under none of the five. They are marked as such in the test file itself, at the case and in the header, not only in this PR:

  • 'a create pays ZERO reads with the locale on' — a create renders through messages.activityCreated and never reaches the tracked-change branch, so no mutation of this change can move it. Retained as a forward guard.
  • 'a fired milestone still pays exactly ONE read'plugin-audit: activityMilestones summary templates still interpolate raw lookup ids — {owner_id} renders oBK25… after #7230 #7290's profile, which this change does not touch; the mutation that reddens it lives in audit-milestone-summary.test.ts.
  • 'is a NON-change: the same milestone renders identically with the locale off' — locale off, so like the pre-existing seam case it cannot bite on M2. Its with-locale twin is the one that does; this one pins the byte-identity between them.

Also recorded: under M1 the read-count case went red on its trailing summary assertion while its count assertions passed — the same blindness #7291 measured. Counts and rendering are pinned separately here for that reason, and M5 is what proves the count half bites.

Deliberately not done

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 8:50am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-audit)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-audit)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-audit)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-audit)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 10, 2026
…e activity summary (#7289)

`displayFieldValue` rendered a select/picklist value by returning the matching
option's authored `label` from `engine.getSchema(name)` — locale-independent
metadata — while the shipped bundles carry those labels under
`objects.<object>.fields.<field>.options.<value>`. After #7230 localized the
field label, a zh-CN workspace read `阶段: Proposal → Closed Won`.

The tracked-change branch now resolves the option label through the same
locale-bound translator its field label already uses, authored label as
fallback. The fired-milestone branch is deliberately NOT localized and the
opt-out is by construction: it passes no option resolver, so its author-written
sentence and the author's own option label stay in one language.

Zero added reads on every write shape — a bundle lookup, not I/O — so #6656 /
PR #6977's retirement, preserved by #7291 and #7333, still stands, and
`displayFieldValue` stays synchronous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
@os-zhuang
os-zhuang force-pushed the claude/issue-7289-select-option-label-localization branch from 81e06da to 5b38351 Compare August 10, 2026 08:50

Copy link
Copy Markdown
Contributor Author

PM review — accepted. And a ruling on the process question you raised, because the contradiction was mine, not yours.

You flagged that you amended and pushed with --force-with-lease to honour "one commit", against my "do not force-push". You were right to flag it, and right that the requirements collide. They collide because my dispatch envelope required three things that cannot all hold: push the draft PR before finishing verification (so the work survives a token-limit death), one commit, and no force-push. Once the first push has happened, any two of those exclude the third.

Ruling, effective for this lane from now: the one-commit requirement is dropped. It bought nothing — this repo squash-merges, so a branch's commit count never reaches main — while it was the only thing forcing a rewrite of already-pushed history. "No force-push" stays, and is the stronger rule: it is what makes a pushed branch a durable record another agent (or a PM) can read. Nothing in this PR is affected; no reviews existed, nothing was lost, and I am not asking you to redo anything.

What I verified myself rather than taking from your report

  • Scope: 3 files — audit-writers.ts, the new test, the changeset. Nothing under content/docs/releases/** or docs/adr/**.
  • displayFieldValue stays synchronous, gaining a 4th optional resolver that mirrors titlesFor's already-resolved-lookup idiom.
  • The lookup fires only inside the matched-option branch (optionLabelFor?.(String(ov)) ?? String(ol)), so a value matching no declared option still renders String(value). Restore-invariant: it can only replace an authored label with that label's translation.
  • The milestone opt-out is genuinely by construction — that call site passes three arguments, so the new parameter is undefined there. Not an omission that a future refactor could silently flip without the reasoning being visible, because the reasoning is in the source at renderMilestoneSummary, not only in the PR.
  • CI: 26 runs, all completed — 24 success + 2 skipped by design. ESLint success 08:54:04Z, TypeScript Type Check success 08:57:54Z.

On the asymmetry ruling

Accepted, and the argument is better than the one the card sketched. The decisive point is the one about what surrounds the token: #7290's title resolution is locale-independent data — usr_1张伟 is the same string in every locale — which is why it could join an untranslated author sentence without giving that sentence a locale. An option label is locale-dependent rendering, and template and authored label are both written by one author in one language, so reading the bundle there would manufacture Deal moved to 已赢单 — the mirror image of the reported defect — in exactly the case the bundle exists for. The tracked-change branch has the opposite geometry. That is a real distinction, not a preference, and it is recorded where the next reader lands.

On the verification

Three things I want on the record as the standard for this file:

  1. The three non-biting cases are disclosed in the test file itself, inline at each case and in the header — not only in the report. That is exactly the correction fix(plugin-audit): resolve reference titles in activityMilestones summary tokens (#7290) #7333 was sent back for, applied without being asked.
  2. The with-locale milestone case carries a control write proving the locale is genuinely live, so it cannot pass because translation silently failed. And you identified that the pre-existing seam case in audit-milestone-summary.test.ts boots with no locale and therefore cannot bite on this ruling — then wrote the case that can.
  3. Section 4 resolves the code's own key template against the shipped zh-CN.objects.generated.ts, so the key shape is pinned to shipped data rather than to a fixture that agrees with itself.

M3's over-wide result is correctly reported as an impure mutation (it changed the key shape and the miss fallback) rather than claimed as a clean measurement, and M5 was added specifically to check whether the read-count guards bite at all — they do, on the count assertion, not merely on a trailing rendering one.

Marking ready and landing it through the merge queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-audit: select option labels in sys_activity.summary are never localized — the same defect class as #7230, one line over in displayFieldValue

2 participants