Skip to content

fix(spec): a bound action's translation is read only under its own object, never from globalActions - #21344

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21261-bound-action-global-fallback
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21261-bound-action-global-fallback

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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 own objectName, never the caller's object:

  • a bound action reads only objects.OBJECT._actions.ACTION;
  • an object-less action reads only globalActions.ACTION.

A bound action no longer falls back to globalActions. That is the scope the TranslationDataSchema.globalActions docblock declares ("Global (object-less) action translations"). os validate already refuses the key at error level. The new private helper actionTranslationNode (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 globalActions at base 1371dc980c, measured with grep -n globalActions on the file (4 code reads, at lines 563, 612, 716 and 744). All four now go through the helper:

  1. lookupActionField (label / description / confirmText / successMessage), read at :563.
  2. lookupActionResultDialogNode, read at :612.
  3. resolveActionOutcomeMessages, read at :716.
  4. lookupActionParamNode, read at :744.

There is no fifth site. Repo-wide, git grep globalActions over packages/**/src finds 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 under objects.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 reach globalActions either. 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 one translateAction result. Each bundle is parsed by TranslationDataSchema, so an author can write it.

Action Copy at Result
bound to support_case globalActions.close_case only source text (and resolveActionLabel answers the source label)
bound to support_case objects.support_case._actions.close_case overlay
bound to support_case objects.support_queue._actions.close_case source text
object-less globalActions.close_case overlay
object-less objects.support_case._actions.close_case source text
inline on object support_case (via translateMetadataDocument('object', …)) globalActions only, then owner-scoped source text, then overlay

There 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 translateObject scopes 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.ts pinned the retired branch. Its test "falls back object-scoped → globalActions per outcome" asserted that a bound action (sys_environment) reads globalActions.delete_environment.outcomeMessages.destroyed. It is replaced: the same bundle now asserts that the bound action keeps its authored destroyed copy. The globalActions entry stays in the fixture as the negative.

Ablation (committed fix, then mutation, then restore). I put the fallback back into actionTranslationNode with node scripts/ablation-replace.mjs: anchor 1 → 0, marker 0 → 1, blob 81d7299d3bcc → ec08660ba646. The test imports ./i18n-resolver relatively, so no dist/ is on its path. Expected direction: red. Observed: 3 failed | 323 passed across i18n-resolver.test.ts and action-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 blob 81d7299d3bcc == the HEAD blob and an empty git diff HEAD.

The lint message (re-read, unchanged)

packages/lint/src/validate-translation-references.ts:1575–:1578 reads: "Action X is bound to object OWNER, so the resolver looks it up under objects.OWNER._actions.X — never under globalActions, 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 (objectui useObjectLabel actionSuffixes) 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 in examples/app-showcase/src/system/translations/index.ts.

Tree Path Key Action binding Disposition
objectstack 1371dc980c examples/app-showcase/src/system/translations/index.ts (en and zh-CN) globalActions.showcase_portfolio_snapshot object-less (examples/app-showcase/src/ui/actions/index.ts: "NO objectName") not a hit; stays
objectstack 1371dc980c 138 tracked files under **/translations/**, including the platform-objects bundles and the other *.generated.ts bundles none n/a 0 hits
objectui 7728c67 and pin 31971ff1e28f apps/console/src/preview-samples.ts:492 (the translation preview sample) globalActions.close_order bound: objectName: 'sales_order' at :191 report-only for objectstack-ai/objectui#11439

No objectstack bundle moves, so no generated translations file is touched.

Liveness ledger (published in @objectstack/spec's files[])

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, and verifiedAt is set to 2026-10-02;
  • packages/spec/liveness/translation.json, objects: the _actions evidence and the note;
  • packages/spec/liveness/action.json, outcomeMessages: the translateAction evidence parenthetical.

check:liveness is 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. TranslationDataSchema parses 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 takes patch (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 (validateTranslationReferences declares no runtimeTypes, so it runs only on flow writes), so such a translation changes the same way.

Verification (opened at 272a47c3b5, patched to 97432a19)

  • pnpm --filter @objectstack/spec build: exit 0.
  • Targeted spec tests: 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.ts and translation.test.ts. Result: 6 files, 498 tests passed.
  • Spec suite chunk src/system src/ui: 144 files, 5257 tests passed.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 85 commands from the merge base. 84 exit 0. That includes three re-runs after their PREREQUISITE NOT MET exit 3 was cleared by a build: check:doc-formula-expressions, check:lean-entry-closure and check: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.
  • Completed after the PR opened, at 272a47c3 (dev report 5946571239 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):
    • the whole spec local project in three chunks: 597 files, every test passed;
    • spec typecheck and check:generated: exit 0;
    • consumer tests: rest 294, runtime 682, service-i18n 16, plugin-approvals 6, objectql 6, all passed.
  • Patch round 1, head 97432a19 (at-tier record 5946714800 ③; 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):
    • the ActionSchema.outcomeMessages TSDoc (packages/spec/src/ui/action.zod.ts) now states the action's one address instead of the retired object-then-globalActions order;
    • spec build, typecheck, check:generated, check:docs, check:liveness, check:doc-authoring and check:nul-bytes: exit 0;
    • dispatch-gates --ran: 86 families, 85 run, 1 NOT-MEASURED (check:type-check-debt; CI's Type Check · debt ledger is the reading).

Acceptance notes

  • The runtime publish door does not run translation-target-unknown on a translation write, because validateTranslationReferences has no runtimeTypes. A translation stored at runtime can therefore carry any key the lint refuses, and the resolver silently never reads it. A bound action's globalActions key 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 (5946727290 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).
  • objectui's useObjectLabel-globalAction.test.tsx docblock says it "Mirrors the canonical @objectstack/spec resolver (lookupActionField): object-scoped wins, global is the fallback." That stops being true with this PR. It is objectui#11439's to update together with actionSuffixes.

claude added 2 commits October 2, 2026 04:26
…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>
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 8 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/liveness/action.json, packages/spec/liveness/translation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/kernel/i18n-standard.mdx (via globalActions (literal, a string literal on a changed line))
  • content/docs/ui/translations.mdx (via globalActions (literal, a string literal on a changed line), user.email (literal, a string literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via globalActions (literal, a string literal on a changed line))

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.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/action.json, packages/spec/liveness/translation.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d29f84b404ce272c9443b5311c6f3d779214348c — the merge of head 97432a19a480cfc368875eea2c923a01a3ab29c0 into base 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 272a47c3b543b118623fc8029c8e69b9110fd06e
Local-runs: none

Inputs read: card #21261 (body and all four comments: triage ruling (a) 5942994297, claim 5945507055, dev report 5946571239, claim amendment 5946596296), PR #21344 (body, 6-file list, net diff), the 35 check-runs on the head, and the repository at refs for the texts the diff cites. Nothing was built, run or re-run.

Check-runs on the head: 35, all completed — 32 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke: conditional jobs), 0 failure, 0 in progress. Type Check · debt ledger is success, which answers the one family the dev left NOT MEASURED locally (check:type-check-debt).

① Derived judgments

  1. Accept set of TranslationDataSchema — unchanged. Right. No key is added, removed or retired; globalActions stays authorable for object-less actions, and _actions and globalActions are built by the one actionTranslationSchema factory (translation.zod.ts:111, :353, :816), which is why the helper's return type NonNullable[TranslationData globalActions][string] is sound for both branches. No tombstone, no retiredKey(), no *_RETIRED_KEY_GUIDANCE entry is owed. Check Changeset and Spec property liveness are green; the dev's check:api-surface read "unchanged" is consistent with the diff.
  2. Public API of @objectstack/spec — unchanged. Right. actionTranslationNode is module-private. translateAction, resolveActionLabel, resolveActionConfirm, resolveActionSuccess, resolveActionResultDialog, translateObject, translateMetadataDocument keep their signatures (i18n-resolver.ts:606, :622, :666, :700, :1098, :1171, :2969 at head).
  3. Runtime behaviour — a bound action no longer reads globalActions. Right, and it is ruling (a). At base 1371dc980c the file carried exactly four globalActions code reads (:563, :612, :716, :744), matching the PR body's count; at head the only code read is inside the helper (:585), and all four lookups index off it. For an object-less action nothing changes: at base the object branch was skipped and the global key read; at head the global key is read. The resolver now agrees with the declaration (translation.zod.ts:802-803, "not bound to a specific object via objectName") and with the lint, which is the card's defect closed from the side triage chose.
  4. The key is the action's own objectName, never the caller's object — holds by construction. Right. translateAction takes no caller object. translateObject scopes an inline action with { ...action, objectName } before handing it to translateAction (head :3031-:3045), so an inline action is bound by declaration and never reaches globalActions. The objectui#3372 case (object-less action on an object's bar) has no spec-side counterpart, as the dev says; the object-less row is the spec's control. The triage's "objectui keys on the caller's object" concern is objectui's half, not reachable here.
  5. Lint — unchanged, and its message re-read true. Right. validate-translation-references.ts:1573-1578 at head: the action is "bound to object OWNER, so the resolver looks it up under objects.OWNER._actions.X — never under globalActions, which is only consulted for object-less actions. This key is never read." Every sentence is now true of the resolver; the hint ("Move these keys under objects.OWNER._actions.X") is the FROM → TO. Severity is still TRANSLATION_TARGET_UNKNOWN_SEVERITY (:1357-1366). The resolver's new header docblock claim that os validate names the object-scoped key to write is therefore true.
  6. No fifth reader. Right. git grep globalActions over packages/*/src (non-test) at head: the CLI extractor is a producer and already files a bound action under objects (cli/src/utils/i18n-extract.ts:1354); i18n-coverage.ts:190 merges bundles; the lint; a conversions key list (conversions/registry.ts:7366); the schema. No other resolver.
  7. Census — no in-repo move owed. Right. showcase_portfolio_snapshot is object-less (examples/app-showcase/src/ui/actions/index.ts:436, "NO objectName"), so its globalActions copy is correctly placed; no generated bundle under **/translations/** carries globalActions. So pnpm check:i18n is indeed not owed and no generated file is touched. The objectui preview-sample hit is objectui#11439's (see ③).
  8. Tests — the pins discriminate. Right. The new describe block reads all four lookups off one translateAction result (read() → field, resultDialog, outcomeMessages, params) across six rows, each bundle parsed by TranslationDataSchema. The ablation reading (3 red: bound/global-only, inline/global-only, the replaced outcome row) is exactly the set that distinguishes fallback from no fallback; rows 2-5 pin behaviour that the mutation does not change, so they staying green under it is expected, not a gap. Fixture triage "replace" on action-outcome-messages.test.ts is right: the old row asserted the retired branch; the globalActions entry stays in the fixture as the negative. Test Core 1/6-6/6 green.
  9. Liveness ledger — text and anchor repoints only, no status change. Right. globalActions row repointed to #actionTranslationNode with verifiedAt bumped to 2026-10-02 on a re-read; objects row's _actions anchor repointed with verifiedAt left at 2026-08-28 — right under the ledger README ("re-stamping the date without the reading is the downgrade"): only the _actions anchor was re-read, not the whole group. action.json outcomeMessages evidence reworded. Spec property liveness green.
  10. Docs — nothing owed in this diff. Right. content/docs/protocol/kernel/i18n-standard.mdx:1053 already states the rule as ruled (PR feat(lint): os validate refuses an action translation's resultDialog title / description / acknowledge when the action declares no resultDialog #21304); the reference tables carry only shape text.
  11. Governed surfaces — none. No path among the six is .claude/**, docs/adr/**, docs/NORTH-STAR.md, skills/**, AGENTS.md or CLAUDE.md; the PR is neither Tier H nor Tier S, so this record is the dispatch's review of record, not a governed-landing credential. Governed Surface Queue Guard green.

② Semver level

.changeset/21261-bound-action-global-fallback.md: '@objectstack/spec': patch, body carrying Clause-②: no with no (widening)/(narrowing) arm; the PR body carries the same Clause-②: no. Right, on all three counts.

  • no is right: ①.1 — the accept set is unchanged, the lint is unchanged, TranslationDataSchema parses the same inputs.
  • Not BREAKING is right: nothing an author can write is removed or renamed; globalActions remains valid for object-less actions. No ADR-0087 disposition is owed, and triage ruled no conversion for a shape the door already refuses at error level. The dev's check-adr-0087-registration and check-changeset-no-major reads are consistent; Check Changeset green.
  • patch is right: a bug fix in a released package (AGENTS.md Post-Task Checklist step 3). The one observable change — a bound action with copy only under globalActions shows source text — is a runtime stopping to honour an input the contract's own door refuses at error level, which is the defect the card names, not a feature. The body states the FROM → TO one-liner and the runtime-stored-translation caveat anyway, which is more than patch requires.
  • skip-changeset would be wrong: the resolver and the two liveness/*.json files ship in @objectstack/spec. Not applied — right.
  • No model identifier in the changeset, the PR title or body.

③ Boundary flags

Dev deviations, each answered:

  1. Claim-surface breach (two liveness/*.json files) — answered by the claim amendment 5946596296; I read both hunks in the diff: text and an anchor repoint, no status change. Closed.
  2. action-outcome-messages.test.ts edited — within the claim's "plus their tests"; the row pinned the retired branch. Closed.
  3. No pre-PR merge of origin/main (AGENTS.md §10) — the head's merge-base with origin/main is 1371dc980c; CI ran the merge ref against 222ecc27f9 (mergeable_state: clean, every check green). Since then main moved three commits to 04f0cc4992 (objectql, a skills doc, plugin-security), none touching packages/spec, packages/lint, the CLI extractor or any of the six files. No joint-breakage exposure measured; the queue's rebuilt generation is the second validation. Not a FAIL item. The seat does §10's scoped re-check only if main moves into packages/spec before landing.
  4. PR opened before the full suite finished — the report carries every reading green and CI confirms. The PR body's "Still running…" bullet under "Verification at open" is now stale; the dev proposed the body edit and may not PATCH — seat's optional edit. Closed.
  5. Three gates exit 3 then 0 after prerequisite builds — ordinary; CI owns them. Closed.
  6. check:type-check-debt NOT MEASURED locally — Type Check · debt ledger success on this head. Closed.
  7. Commit trailers use the model-free pair — that is AGENTS.md's own spelling, and AGENTS.md outranks the harness reminder. Closed.
  8. No spec counterpart to the objectui#3372 control — verified by construction (①.4). Closed.

open_questions: none declared; nothing to answer.

Dev out_of_scope_findings, each escalated:

Reviewer's own finding — one text the dev's census missed:

  • packages/spec/src/ui/action.zod.ts:1350-1351 (the outcomeMessages property docblock) still reads: "Translation rides beside successMessage: objects.OBJECT._actions.ACTION.outcomeMessages.OUTCOME, then globalActions.ACTION.outcomeMessages.OUTCOME (translateAction)." The "then" states the retired order for translateAction, and src/**/*.zod.ts is in @objectstack/spec's files[], so this text ships — the same class the dev's rule-3 exception (text a release would freeze as wrong) made it fix the three ledger texts. No gate reads it; the .describe() customer string beside it is correct; the accept set and the resolver are unaffected, so it does not change the verdict on this head. Escalated to the seat as owed text: either fold the one-line rewording into this PR (a claim amendment for that docblock only, and a fresh record on the new head), or a docs-only follow-up PR before the release that carries this changeset.

Implemented-by: claude/issue-21261-bound-action-global-fallback
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 97432a19a480cfc368875eea2c923a01a3ab29c0
Local-runs: none

Inputs read: card #21261 (body and all six comments: triage ruling (a) 5942994297, claim 5945507055, dev reports 5946571239 and 5947320948, claim amendments 5946596296 and 5946727290), PR #21344 (body, the 7-file list, the net diff against main), the earlier record 5946714800 on head 272a47c3 as a thread input, the check-runs on this head, and the repository at refs (git show / git grep / git diff on fetched commits) for the texts the diff cites. Nothing was built, run or re-run; no worktree, no checkout. This head adds one commit to 272a47c3; the whole net diff (7 files, +176/-51) is judged.

Check-runs on the head, read at 2026-10-02T07:35Z: 42, all completed — 37 success, 5 skipped, 0 failure, 0 in progress. Two run sets share this sha: the push set (32 success; Console Pin Gate, Build Docs, Packed-tarball smoke skipped as conditional jobs) and a second pull_request-event set created two minutes after the round-1 dev report, which re-ran the five PR guards and Check Changeset (all success; Check PR Size and Auto Label skipped there). At this reviewer's first read five of that second set were still in_progress; this record reads the completed state. All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Type Check · debt ledger is success, which answers the one family the dev left NOT MEASURED in both rounds. CI evaluated the merge ref d29f84b404, whose two parents are this head and main at 6c5bef5f4e.

① Derived judgments

  1. Accept set of TranslationDataSchema — unchanged. Right. translation.zod.ts is not in the diff; globalActions stays authorable for object-less actions; no key is added, removed or retired, so no tombstone, retiredKey() or guidance-map entry is owed. The action.zod.ts hunk sits entirely inside the doc comment above outcomeMessages: and leaves the .describe() string untouched, so the authorable surface, the generated references and check:docs see no change (TypeScript Type Check, which runs the spec gates in sequence, is green; Spec property liveness green).
  2. Public API of @objectstack/spec — unchanged. Right. actionTranslationNode (i18n-resolver.ts:578) is module-private; translateAction, resolveActionLabel, resolveActionConfirm, resolveActionSuccess, resolveActionResultDialog, translateObject and translateMetadataDocument keep their signatures. The helper's return type — the value type of the globalActions record — is sound for both branches because _actions (translation.zod.ts:353) and globalActions (:816) are built by the one actionTranslationSchema factory (:111).
  3. Runtime: a bound action reads only objects.OBJECT._actions.ACTION; an object-less action reads only globalActions.ACTION. Right, and it is ruling (a). The helper (:581-:585) is the one predicate; all four lookups index off it — lookupActionField (:596), lookupActionResultDialogNode (:640), resolveActionOutcomeMessages (:739), lookupActionParamNode (:762). The four removed hunks are the four globalActions reads the base carried (old :562, :612, :716, :744), matching the PR body's count. For an object-less action nothing changes in effect: at base the object branch was skipped and the global key read; at head the global key is read, through the same empty-string guard in the two string lookups and the same undefined-on-missing in the two node lookups. The resolver now agrees with the declaration (translation.zod.ts:801-:803, "not bound to a specific object via objectName") and with the lint — the card's defect, closed from the side triage chose.
  4. The key is the action's own objectName, never the caller's object — holds by construction. Right. translateAction takes no caller object. translateObject (:3031-:3045) hands an inline action that carries its own objectName straight to translateAction (:3034) and scopes an inline action without one to its declaring object ({ ...action, objectName }, :3039-:3043), stripping the synthetic field afterwards. So an inline action is bound by declaration and never reaches globalActions. The objectui#3372 case (object-less action rendered on an object's bar) has no spec-side input to pin; the object-less row is the spec's control, as the dev says.
  5. Lint — unchanged, message re-read true. Right. validate-translation-references.ts:1573-1578 at head: the action "is bound to object OWNER, so the resolver looks it up under objects.OWNER._actions.X — never under globalActions, which is only consulted for object-less actions. This key is never read." Every sentence is now true of the resolver; the hint (:1580, "Move these keys under objects.OWNER._actions.X") is the FROM → TO; severity stays TRANSLATION_TARGET_UNKNOWN_SEVERITY (:1359). The resolver's new header claim that os validate names the object-scoped key to write (:42-:44) is therefore true.
  6. No fifth reader. Right. git grep globalActions at head over packages/, examples/, skills/ and content/docs/ (non-test): the only runtime read is the helper (i18n-resolver.ts:585); cli/src/utils/i18n-extract.ts:1351-1354 is the producer and already files an action with an objectName under objects.OBJECT._actions; i18n-coverage.ts:190 merges bundles; conversions/registry.ts:7366 is a group-name list; the rest is the schema, the lint, docs, release-owned CHANGELOGs and the ledger.
  7. Census — no in-repo move owed. Right. The showcase bundle's two globalActions blocks (examples/app-showcase/src/system/translations/index.ts:313, :996) name showcase_portfolio_snapshot, which declares "NO objectName" (examples/app-showcase/src/ui/actions/index.ts:436); no generated bundle under **/translations/** carries globalActions. So pnpm check:i18n is not owed and no generated file is touched. The objectui preview-sample hit is objectui#11439's (③).
  8. Tests — the pins discriminate. Right. The new describe block reads all four lookups off one translateAction result across six rows (bound: global-only, owner-scoped, other-object; object-less: global-only, owner-scoped; inline via translateMetadataDocument('object', …)), each bundle parsed by TranslationDataSchema. The dev's ablation reading (3 red: bound/global-only, inline/global-only, the replaced outcome row) is exactly the set that distinguishes fallback from no fallback. Fixture triage "replace" on action-outcome-messages.test.ts:206-213 is right: the old row asserted the retired branch for sys_environment; the globalActions.delete_environment entry stays in the fixture as the negative. Test Core 1/6 to 6/6 green.
  9. Liveness ledger — text and anchor repoints only, no status change. Right. translation.json globalActions: evidence repointed to #actionTranslationNode, note rewritten, verifiedAt bumped to 2026-10-02 on a real re-read; objects: only the _actions anchor repointed, verifiedAt left at 2026-08-28 — right under the ledger README's "re-attestation means re-reading, never re-stamping" (liveness/README.md:311-:314): the other four readers of that group were not re-read. action.json outcomeMessages: evidence parenthetical reworded; status: planned, authorWarn and carrier unchanged. Spec property liveness green. No hand-written path of the seven is merge=os-regen-routed (only liveness/state-counts/** is, .gitattributes:166).
  10. This round's text — action.zod.ts:1349-:1352. Right, and it discharges the earlier record's owed text. The outcomeMessages TSDoc now states the action's one address for translateAction and reads the same as the resolver docblock (i18n-resolver.ts:563-:566); the customer-facing .describe() beside it was already correct and is untouched. The dev's re-sweep of packages/spec/src/** (non-test) is confirmed by my own: no remaining site in this repository's shipped source states object-scoped-then-globalActions for a bound action.
  11. The three texts left standing describe objectui's reader, which is still object-then-global at the pin. Right to leave, with one addition to the child's list. action.zod.ts:956-958 and translation.zod.ts:124-128 (excluded by the seat's amendment 5946727290) both cite objectui packages/i18n/src/useObjectLabel.ts:463, as does liveness/action.json:16 (the description row's evidence), which the amendment does not name. All three are true at pin 31971ff1e28f and go stale on the day objectui#11439 lands — that child's checklist, not this PR's.
  12. Docs and skills — nothing owed. Right. content/docs/protocol/kernel/i18n-standard.mdx:1053 already states the rule as ruled (PR feat(lint): os validate refuses an action translation's resultDialog title / description / acknowledge when the action declares no resultDialog #21304) and :1040-:1041 names the two addresses with no order; content/docs/ui/translations.mdx:84 and skills/objectstack-i18n/SKILL.md:170 state no order. CHANGELOG mentions of the old order (spec/CHANGELOG.md:1245, :21024; cli, client) are release-owned history and stay as shipped.
  13. Governed surfaces — none. No path among the seven is .claude/**, docs/adr/**, docs/NORTH-STAR.md, skills/**, AGENTS.md or CLAUDE.md; neither Tier H nor Tier S. This record is owed because the diff touches packages/spec/src/** non-test, and it is the dispatch's review of record, not a governed-landing credential. Governed Surface Queue Guard green. 227 changed lines (size/m), far under the 5,000-line human-merge threshold.
  14. Attribution — clean. No model identifier in the PR title, body, changeset or the three commits (6687a58f6d, 272a47c3b5, 97432a19a4), whose trailers are AGENTS.md's model-free pair.

② Semver level

.changeset/21261-bound-action-global-fallback.md: '@objectstack/spec': patch, body carrying Clause-②: no with no (widening)/(narrowing) arm; the PR body carries the same Clause-②: no. Right on all counts.

  • Clause-②: no is right: the accept set is unchanged (①.1), the lint is unchanged (①.5), TranslationDataSchema parses the same inputs.
  • Not BREAKING is right: nothing an author can write is removed or renamed; globalActions remains valid for object-less actions; no ADR-0087 disposition is owed, and triage ruled out a conversion for a shape the door already refuses at error level. Check Changeset success on both run sets.
  • patch is right: a bug fix in a released package (AGENTS.md Post-Task Checklist step 3). The one observable change — a bound action with copy only under globalActions shows its source text — is the runtime ceasing to honour an input the contract's own door refuses, which is the defect the card names. The body states the FROM → TO one-liner and the runtime-stored-translation caveat anyway.
  • skip-changeset would be wrong and is not applied: the resolver, the action.zod.ts comment and the two liveness/*.json files all ship in @objectstack/spec.

③ Boundary flags

Dev deviations, round 0 (5946571239), each answered:

  1. Claim-surface breach (two liveness/*.json files) — answered by amendment 5946596296; both hunks are text and an anchor repoint, no status change (①.9). Closed.
  2. action-outcome-messages.test.ts edited — within the claim's "plus their tests"; the row pinned the retired branch (①.8). Closed.
  3. No pre-PR merge of origin/main (AGENTS.md §10) — now a live landing item, not a FAIL item. The head's merge-base with origin/main is still 1371dc980c; CI's merge ref was built onto 6c5bef5f4e; since then main has moved 7 commits to 23365eaedf, and two of them edit this PR's own files: 99e1912afc (feat(spec): retire the metric sub-caption — the widget translation subCaption key and translateDashboard's options.description overlay #21342, the widget sub-caption retirement) touches i18n-resolver.ts in the dashboard regions (around :1267, :1470, :1485, :1581 on main), i18n-resolver.test.ts, and the dashboards row of liveness/translation.json (:72-:74); 23365eaedf (feat(spec)!: the AI runtime's two JSON-Schema slots refuse an untyped subschema that carries a type-scoped keyword, at its path (#21289) #21353) touches action.zod.ts at the import block and ActionAiSchema.outputSchema (:33, :793-:810) and translation.zod.ts, which this diff does not touch. Every one of those regions is disjoint from this PR's hunks (i18n-resolver.ts:29-:47, :555-:765; translation.json:30-:31, :67-:69, two unchanged lines above main's change; action.zod.ts:1349-:1352), and none of the seven paths is os-regen-routed, so GitHub's text merge and a local one answer alike. GitHub's mergeable_state: clean is a reading against 6c5bef5f4e (the merge commit's parents), stale by those 7 commits. Escalated to the seat as the landing step: let the queue's rebuilt generation validate the merge (it runs the seven required contexts, the spec gates included), or merge origin/main and run §10's scoped re-check (pnpm --filter @objectstack/spec build && check:generated) — noting that a merge moves the head and owes a fresh record, since the pure-regeneration exception does not cover a merge.
  4. PR opened before the full suite finished — the body now carries the readings under "Verification (opened at 272a47c3b5, patched to 97432a19)"; the stale "Still running" bullet is gone. Closed.
  5. Three gates exit 3 then 0 after prerequisite builds — ordinary; CI owns them. Closed.
  6. check:type-check-debt NOT MEASURED locally, both rounds — Type Check · debt ledger success on this head. Closed.
  7. Model-free commit trailers — AGENTS.md's own spelling, which outranks the harness reminder; verified on all three commits (①.14). Closed.
  8. No spec counterpart to the objectui#3372 control — by construction (①.4). Closed.

Dev deviations, round 1 (5947320948), each answered:

  1. Angle-bracket placeholders kept in the source TSDoc — right: a source comment is not a GitHub body, and the file's own convention and the resolver docblock use that spelling; the capitals belong to GitHub text only. Closed.
  2. Worktree removed and recreated at 272a47c3b5 — procedural; the readings are on this head. Closed.
  3. Whole derived set re-run instead of only the new family — more than asked, nothing lost. Closed.

open_questions: none declared in either report; nothing to answer.

Dev out_of_scope_findings (round 0; round 1 declares none), each escalated or closed:

The earlier record's own finding (action.zod.ts:1350-1351, the outcomeMessages TSDoc stating the retired order) is discharged by this head (①.10). Closed.

State not reviewed here: the PR is still a draft; readying and arming are the seat's landing acts.

Implemented-by: claude/issue-21261-bound-action-global-fallback
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 07:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 3911901 Oct 2, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21261-bound-action-global-fallback branch October 2, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants