fix(plugin-audit): localize select option labels in the tracked-change activity summary (#7289) - #7389
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…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
81e06da to
5b38351
Compare
|
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 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 What I verified myself rather than taking from your report
On the asymmetry rulingAccepted, 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 — On the verificationThree things I want on the record as the standard for this file:
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 |
Fixes #7289
displayFieldValuerendered a select/picklist value by scanningfield.options[]and returning the matching option's authoredlabel.field.optionscomes fromengine.getSchema(name)— locale-independent metadata — while the shipped bundles carry those same labels underobjects.[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— a half-localized string at the bottom of a fully-localized page.
sys_activity.summaryis 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 Setupsys_activitylist and mobile/REST/SDUI simultaneously.Anchors re-derived at the branch point (
3c03725d2), because the card's are staleThe 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.a768f81a1)3c03725d2displayFieldValue:308:370— and it already took a third parameter (titlesFor, #7230):310-319:376-386renderTrackedChangeSummary:441(caller 1)renderMilestoneSummary:575(caller 2, added by #7290):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 throughmatchMilestone— it split it:matchMilestone(sync detection, no longer takesfields) →planMilestoneTokenReads→renderMilestoneSummary.matchMilestonenever callsdisplayFieldValueat 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:376still returnedString(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.
displayFieldValuehas 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:activityMilestonessummary templates still interpolate raw lookup ids —{owner_id}rendersoBK25…after #7230 #7290 ruled translating templates a contract decision and left it; that ruling is one commit old and this PR does not reopen it.activityMilestonessummary templates still interpolate raw lookup ids —{owner_id}rendersoBK25…after #7230 #7290's own change — a reference id resolving to the referenced record's title — is locale-independent data.usr_1→张伟is the same string in every locale, which is why it could be added to an untranslated sentence without giving that sentence a locale. An option label is locale-dependent rendering. "The milestone branch already got plugin-audit:activityMilestonessummary templates still interpolate raw lookup ids —{owner_id}rendersoBK25…after #7230 #7290's treatment" is therefore not an argument that it should get this one.Deal moved to 已赢单) in exactly the case the bundle exists for — authoring language ≠ workspace locale — trading the reported half-localized string for its mirror image. The tracked-change branch has the opposite geometry, so there the authored value is the mismatch and translating it is the fix.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
displayFieldValuegains a fourth optional parameter,optionLabelFor, mirroring exactly the idiomtitlesForestablished: 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.renderTrackedChangeSummarybuilds 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:The zero-read guards run against
deal_2, a row that already holdsownerandaccount_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 realObjectQLengine with the copy-returning counting driver.The locale fixture is a real
createMemoryI18nbundle. 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 contracttranslateWithdepends on is pinned beside it.Fallbacks are covered per value, not per field:
stage.options.on_holdandpriority.options.p2are deliberately absent from the bundle, so阶段: 提案中 → On Holdpins 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
textfield carrying an advisoryoptionsarray, not aselect. That is forced, and measured —record-validatorenforces "select / multiselect: value must appear inoptions" and refuses the write, so aselectcannot express that state through a real engine at all.displayFieldValue's option branch keys onfield.optionsbeing an array, not onfield.type, so thetextfield reaches exactly the same branch.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 -- ..., nevergit stash.expected '阶段: Proposal → Closed Won' to be '阶段: 提案中 → 已赢单'.label, not.options.[value])String(value)case onlyexpected 1 to be +0, and 6 further reds inside #7291's and #7290's own suitesM2 is the mutation that matters, because it is the ruling. Applying the rejected design — threading
objectName+translateintorenderMilestoneSummary— 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)'inaudit-milestone-summary.test.ts— boots with no locale, sotranslatereturns 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 inaudit-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 throughmessages.activityCreatedand 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:activityMilestonessummary templates still interpolate raw lookup ids —{owner_id}rendersoBK25…after #7230 #7290's profile, which this change does not touch; the mutation that reddens it lives inaudit-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
activityMilestonessummary templates still interpolate raw lookup ids —{owner_id}rendersoBK25…after #7230 #7290's ruling, untouched).sys_activity.summaryis.multiple: trueselects still fall through toString(value)— an array never equals a scalar option value, exactly as before this change.content/docs/releases/**ordocs/adr/**.