Repository navigation
fix(spec): a bound action's translation is read only under its own object, never from globalActions - #21344
Conversation
…ions The four action lookups in the spec i18n resolver read one node, chosen by the action's own objectName: objects.OBJECT._actions.ACTION for a bound action, globalActions.ACTION for an object-less one. A bound action no longer falls back to globalActions, which TranslationDataSchema declares for object-less actions and os validate refuses for a bound one. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…changeset The globalActions and objects ledger rows cited the per-lookup fallback that no longer exists; they now cite actionTranslationNode, the one node every action lookup reads. The outcomeMessages row's evidence says the same. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d29f84b404ce272c9443b5311c6f3d779214348c && git checkout d29f84b404ce272c9443b5311c6f3d779214348c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5 97432a19a480cfc368875eea2c923a01a3ab29c0 && git checkout -B drift-repro 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5 && git merge --no-ff 97432a19a480cfc368875eea2c923a01a3ab29c0
node scripts/docs-audit/affected-docs.mjs --json 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5
|
Contract reviewServed-tier: Inputs read: card #21261 (body and all four comments: triage ruling (a) Check-runs on the head: 35, all ① Derived judgments
② Semver level
③ Boundary flagsDev
Dev
Reviewer's own finding — one text the dev's census missed:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…slation address The property docblock still described the retired object-scoped-then- globalActions order. It now says a bound action reads its object's _actions copy and an object-less action reads globalActions. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #21261 (body and all six comments: triage ruling (a) Check-runs on the head, read at 2026-10-02T07:35Z: 42, all ① Derived judgments
② Semver level
③ Boundary flagsDev
Dev
Dev
The earlier record's own finding ( State not reviewed here: the PR is still a draft; readying and arming are the seat's landing acts. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21261
Clause-②: no
What changes
The spec i18n resolver now reads an action's translated copy at one address, chosen by the action's own
objectName. This implements triage ruling (a) on the card (comment 5942994297). The key is the action's ownobjectName, never the caller's object:objects.OBJECT._actions.ACTION;globalActions.ACTION.A bound action no longer falls back to
globalActions. That is the scope theTranslationDataSchema.globalActionsdocblock declares ("Global (object-less) action translations").os validatealready refuses the key at error level. The new private helperactionTranslationNode(packages/spec/src/system/i18n-resolver.ts) is the one predicate. Every action lookup reads its leaf off that node.The four sites that read
globalActionsat base1371dc980c, measured withgrep -n globalActionson the file (4 code reads, at lines 563, 612, 716 and 744). All four now go through the helper:lookupActionField(label / description / confirmText / successMessage), read at:563.lookupActionResultDialogNode, read at:612.resolveActionOutcomeMessages, read at:716.lookupActionParamNode, read at:744.There is no fifth site. Repo-wide,
git grep globalActionsoverpackages/**/srcfinds no other runtime reader. The CLI extractor (packages/cli/src/utils/i18n-extract.ts) is the producer, and it already writes a bound action's keys underobjects.OBJECT._actions. The header docblock (:32–:44) and the docblocks on the lookups now say the global key is read only for an object-less action, and they name the declaration.translateObject's inline actions are unchanged. An inline action is scoped to the object that declares it, so it is bound and does not reachglobalActionseither. The new tests pin this.Pins (all four lookups, both directions)
packages/spec/src/system/i18n-resolver.test.ts, describe "translateAction — the copy address is keyed on the action's own objectName". Every row reads all four lookups off onetranslateActionresult. Each bundle is parsed byTranslationDataSchema, so an author can write it.support_caseglobalActions.close_caseonlyresolveActionLabelanswers the source label)support_caseobjects.support_case._actions.close_casesupport_caseobjects.support_queue._actions.close_caseglobalActions.close_caseobjects.support_case._actions.close_casesupport_case(viatranslateMetadataDocument('object', …))globalActionsonly, then owner-scopedThere is no spec counterpart to objectui#3372's case: an object-less action rendered on an object's bar, with the caller passing an object. The spec resolver takes no caller object for a standalone action document. Only
translateObjectscopes an action to a host, and that is an inline action, which is bound by declaration. The object-less row above is this repository's half of that control.Fixture triage.
packages/spec/src/ui/action-outcome-messages.test.tspinned the retired branch. Its test "falls back object-scoped → globalActions per outcome" asserted that a bound action (sys_environment) readsglobalActions.delete_environment.outcomeMessages.destroyed. It is replaced: the same bundle now asserts that the bound action keeps its authoreddestroyedcopy. TheglobalActionsentry stays in the fixture as the negative.Ablation (committed fix, then mutation, then restore). I put the fallback back into
actionTranslationNodewithnode scripts/ablation-replace.mjs: anchor 1 → 0, marker 0 → 1, blob81d7299d3bcc→ec08660ba646. The test imports./i18n-resolverrelatively, so nodist/is on its path. Expected direction: red. Observed: 3 failed | 323 passed acrossi18n-resolver.test.tsandaction-outcome-messages.test.ts: the bound/global-only row, the inline row and the replaced outcome row. Restore:git checkout HEAD -- ABS_PATH. Proved by blob81d7299d3bcc== the HEAD blob and an emptygit diff HEAD.The lint message (re-read, unchanged)
packages/lint/src/validate-translation-references.ts:1575–:1578reads: "Action X is bound to object OWNER, so the resolver looks it up underobjects.OWNER._actions.X— never underglobalActions, which is only consulted for object-less actions. This key is never read." Every sentence is now true of the spec resolver. The lint file stays out of the diff. The console reader (objectuiuseObjectLabelactionSuffixes) still appends the global key for a bound action until objectstack-ai/objectui#11439 lands. That is the objectui half, and this PR does not edit objectui.Census (before retiring)
Positive control:
git grep -c globalActions -- 'examples/**'returns 6, all inexamples/app-showcase/src/system/translations/index.ts.1371dc980cexamples/app-showcase/src/system/translations/index.ts(en and zh-CN)globalActions.showcase_portfolio_snapshotexamples/app-showcase/src/ui/actions/index.ts: "NO objectName")1371dc980c**/translations/**, including the platform-objects bundles and the other*.generated.tsbundles7728c67and pin31971ff1e28fapps/console/src/preview-samples.ts:492(the translation preview sample)globalActions.close_orderobjectName: 'sales_order'at:191No objectstack bundle moves, so no generated translations file is touched.
Liveness ledger (published in
@objectstack/spec'sfiles[])Three ledger texts described the retired fallback. This change made them false, so they are corrected here:
packages/spec/liveness/translation.json,globalActions: the evidence is repointed to#actionTranslationNode, the note now says the group is read only for an object-less action, andverifiedAtis set to 2026-10-02;packages/spec/liveness/translation.json,objects: the_actionsevidence and the note;packages/spec/liveness/action.json,outcomeMessages: thetranslateActionevidence parenthetical.check:livenessis green. These files were not in the claim's declared file surface. The report names that.Changeset
.changeset/21261-bound-action-global-fallback.md:'@objectstack/spec': patch,Clause-②: no. No accept set widens or narrows.TranslationDataSchemaparses the same input, and the lint is unchanged. The resolver stops honouring a key outside the declaration that the authoring doors already refuse at error level. That is a bug fix in a released package, so it takespatch(AGENTS.md, Post-Task Checklist step 3). The changeset gives the one-line fix for a bundle that kept such a key. It also notes that a translation stored at runtime is not checked by that lint rule (validateTranslationReferencesdeclares noruntimeTypes, so it runs only onflowwrites), so such a translation changes the same way.Verification (opened at
272a47c3b5, patched to97432a19)pnpm --filter @objectstack/spec build: exit 0.i18n-resolver.test.ts,i18n-resolver.object-list-views.test.ts,i18n-resolver.nav-label-identity.test.ts,action-outcome-messages.test.ts,action-description.test.tsandtranslation.test.ts. Result: 6 files, 498 tests passed.src/system src/ui: 144 files, 5257 tests passed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 85 commands from the merge base. 84 exit 0. That includes three re-runs after theirPREREQUISITE NOT METexit 3 was cleared by a build:check:doc-formula-expressions,check:lean-entry-closureandcheck:dual-build-cjs-loads. NOT MEASURED:pnpm check:type-check-debt. It is a whole-tree tsc re-measure, and it was killed by the foreground cap at about 570s twice. It is left to CI.272a47c3(dev report5946571239on lint: os validate refuses a bound action's globalActions translation key as "never read", but the spec's own i18n resolver reads it as the object-scoped key's fallback #21261):typecheckandcheck:generated: exit 0;rest294,runtime682,service-i18n16,plugin-approvals6,objectql6, all passed.97432a19(at-tier record5946714800③; dev report on lint: os validate refuses a bound action's globalActions translation key as "never read", but the spec's own i18n resolver reads it as the object-scoped key's fallback #21261):ActionSchema.outcomeMessagesTSDoc (packages/spec/src/ui/action.zod.ts) now states the action's one address instead of the retired object-then-globalActionsorder;build,typecheck,check:generated,check:docs,check:liveness,check:doc-authoringandcheck:nul-bytes: exit 0;dispatch-gates --ran: 86 families, 85 run, 1 NOT-MEASURED (check:type-check-debt; CI'sType Check · debt ledgeris the reading).Acceptance notes
translation-target-unknownon a translation write, becausevalidateTranslationReferenceshas noruntimeTypes. A translation stored at runtime can therefore carry any key the lint refuses, and the resolver silently never reads it. A bound action'sglobalActionskey now joins that set. I did not measure a producer that writes this shape at runtime, so this is noted rather than filed. The seat ruled it out of this card's scope in writing (5946727290on lint: os validate refuses a bound action's globalActions translation key as "never read", but the spec's own i18n resolver reads it as the object-scoped key's fallback #21261).useObjectLabel-globalAction.test.tsxdocblock says it "Mirrors the canonical@objectstack/specresolver (lookupActionField): object-scoped wins, global is the fallback." That stops being true with this PR. It is objectui#11439's to update together withactionSuffixes.