feat(lint): os validate refuses an action translation's resultDialog title / description / acknowledge when the action declares no resultDialog - #21304
Conversation
… a declared dialog A `resultDialog.title`, `.description` or `.acknowledge` key under an action that declares no `resultDialog` is now `translation-target-unknown` at error, under both `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`. `resolveActionResultDialog` returns before any lookup when the action declares no dialog, so that copy was read by nothing while `os validate` passed it. Under a declared dialog every leaf is read and nothing changes. The action-entry coverage pin moves the three leaves from leaf-copy to reference-checked, each backed by a ghost at both addresses. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
… entry The `os validate` rule table in i18n-standard gains the action-entry keys the walk judges: `params.NAME.options.VALUE`, `outcomeMessages.OUTCOME`, `resultDialog.fields.PATH` and the dialog's `title` / `description` / `acknowledge` leaves, at both action addresses. The `globalActions` row states that a bound action's copy lives only under its object. Adds the `@objectstack/lint` changeset. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 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 a5be1236b3bc7dfd29090d5d9dbbf03969c515c6 && git checkout a5be1236b3bc7dfd29090d5d9dbbf03969c515c6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4e530568a27d750f6b895a2f8b2c131be849857d b82d990cb98b126724907c43b8fb7018a087bdcf && git checkout -B drift-repro 4e530568a27d750f6b895a2f8b2c131be849857d && git merge --no-ff b82d990cb98b126724907c43b8fb7018a087bdcf
node scripts/docs-audit/affected-docs.mjs --json 4e530568a27d750f6b895a2f8b2c131be849857d |
Contract reviewServed-tier: Inputs read: card #21264 (body; comments 5943006586, 5943050672, 5944251774, 5944702775); card #21261 (body; ruling 5942994297); PR #21304 (body, file list, net diff of ① Derived judgments
② Semver level
Remedy, one patch round, code and tests untouched: in ③ Boundary flags
Check-runs on the head, read at 2026-10-02T03:10Z: 35 check-runs, every one concluded — 33 Implemented-by: VERDICT: FAIL In one line: ① holds at the code and the pin; ② the declaration misstates the direction and the changeset omits both mandatory breaking carriers, and the bare Generated by Claude Code |
…lobalActions row states the os validate rule The changeset's Clause-② line reads `no (narrowing)`: the diff refuses keys `os validate` passed and widens nothing. A BREAKING banner heads the paragraph that tells a project what changes. The i18n-standard `globalActions` row now states only what `os validate` does: a key naming a bound action is an error that names the object-scoped key to write instead. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…refusal One `not-required (no-migration-prescription)` marker: no spec key, export or stored value moves, so there is nothing for `objectstack migrate meta` to convert, and the refusal's remedy is the author's choice, not a mechanical rewrite. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: A narrow record on patch round 2. It answers the FAIL on ② in record Inputs read: record ① Derived judgmentsCarry, verified by blob id. ② Semver level
③ Boundary flags
Check-runs on the head, read at 2026-10-02T03:41Z: 42 check-runs, every one concluded — 38 Implemented-by: VERDICT: PASS In one line: ① carries by blob id; ② the arm reads Generated by Claude Code |
Fixes #21264
Clause-②: no (narrowing)
What changed
validateTranslationReferences(packages/lint/src/validate-translation-references.ts) is the rule behindtranslation-target-unknown, whichos validate,os buildandos lintall run. Its action-entry walk (checkActionEntry) now judges the result dialog's prose leaves,resultDialog.title,resultDialog.descriptionandresultDialog.acknowledge, at both action addresses,objects.OBJECT._actions.ACTIONandglobalActions.ACTION.resultDialog.title/.description/.acknowledgeresultDialogat alltranslation-target-unknown· error (new)resolveActionResultDialogresultDialog.fields.PATHresultDialog.fields[].pathtranslation-target-unknown· error (unchanged)resolveActionResultDialogtitleonto a dialog that declares notitle, so declaring the dialog is the whole condition.coverage vs the action translation shape) moves the three leaves fromleaf-copytoreference-checked. Each is backed by a ghost that must report at both addresses, under a new hostnoDialog(an action that declares noresultDialog).resultDialogitself stays acontainer.content/docs/protocol/kernel/i18n-standard.mdxgains every keyed child of an action entry the walk judges:params.NAME.options.VALUE,outcomeMessages.OUTCOME,resultDialog.fields.PATHand this card's leaves. A lead sentence says these rows hold at both action addresses. TheglobalActionsrow states only whatos validatedoes today: it resolves when it names an action with noobjectName, and a key naming a bound action is an error whose message names theobjects.OWNER._actions.ACTIONkey to write instead, OWNER being the action's ownobjectName. It says nothing about where the copy is read from; both resolvers still fall back toglobalActions.ACTIONfor a bound action until 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 lands..changeset/21264-result-dialog-leaves.md,@objectstack/lintminor, withClause-②: no (narrowing), a BREAKING banner heading its "What changes for a project" paragraph, and one ADR-0087 disposition,not-required (no-migration-prescription), with its reason in writing.Why
resolveActionResultDialog(packages/spec/src/system/i18n-resolver.ts) returns the action's ownresultDialoguntouched when the action declares none, before any lookup. So a dialog leaf under such an action is read by nothing, whileos validatepassed it clean. A bundle can translate a dialog the action declares. It cannot add one.Measured
All readings below are on this branch,
objectstack-ai/objectstack. The base isorigin/main4e6dc2338a, and HEAD is46b40dc845.The door,
os validate --json, before and after. The probe stack has a bound actionplain_probewith noresultDialog. Its bundle carriestitle,descriptionandacknowledgeunderresultDialog, plus aparams.ghostcontrol. A bound actiondialog_probedeclares a dialog with notitle, and its bundle carries all three leaves. An object-lessplain_globalwith no dialog carriesresultDialog.title. An object-lessdialog_globaldeclares a dialog and carries all three leaves.4e6dc2338a46b40dc845..._actions.plain_probe.params.ghost(control)..._actions.plain_probe.resultDialog.title/.description/.acknowledgeglobalActions.plain_global.resultDialog.title..._actions.dialog_probe.resultDialog.*(declared dialog)globalActions.dialog_global.resultDialog.*(declared dialog)Before: 1 error. After: 5 errors, and both runs exit 1.
The runtime read.
translateAction(@objectstack/spec/system, dist), on the same bundle inzh-CN:plain_probeandplain_globalreturn noresultDialog, so the leaves are never read.dialog_probereturnstitle令牌已生成,description请妥善保存andacknowledge已保存, though its own dialog sets notitle. The field labeltokenis令牌.dialog_globalreturns已生成,全局说明and好的.So the control is a real read.
Census.
os validate --jsonon each example app at HEAD:app-crmexit 0,app-todoexit 0,app-showcaseexit 0,app-multi-packageexit 0. That is 0 findings on anyresultDialogpath.@objectstack/platform-objects' 50 objects and its shipped four-locale bundle (dist): the bundle carries 108 dialog leaf keys, from 9 actions × 3 leaves × 4 locales. All 108 sit under a declaring action. There are 0 findings on aresultDialogpath, and 16 findings in total, the same total lint:os validatenever judges a translation bundle's_actions.ACTION.outcomeMessages.OUTCOMEkeys against the outcomes the action declares — an undeclared outcome's copy passes clean and is never read #21216's dev read.git grepover the 98 tracked translation-bundle files:resultDialogappears only inplatform-objects' four locale files. No example bundle carries one.Ablation. Each leg is committed first, then mutated through
scripts/ablation-replace.mjs, which uses a literal anchor, proves the mutation is on disk by blob hash, and restores withgit checkout HEAD. The test imports the rule fromsrcby a relative path, so nodistbuild sits between the mutation and the run.checkActionResultDialogLeaves(findings, ctx);. Blobde2dc9a541a9becomes8133e71705ce. 3 tests fail and 149 pass: both new refusals, and the coverage pin's backing test. Restored: blob equals HEAD andgit diff HEADis empty.if (isRec(ctx.action.resultDialog)) return;. Blobde2dc9a541a9becomes1892f4a64d54. 4 tests fail and 148 pass: this card's declared-dialog control, theresultDialog.fieldscontrol (accepts every declared path…),accepts the real instance of every keyed group…, and the existing dotted-path refusal. Each of the four carries a translated leaf under a declared dialog. Restored the same way.Tests (at HEAD
46b40dc845)pnpm --filter @objectstack/lint test: 119 files and 5518 tests pass.pnpm --filter @objectstack/lint typecheck: exit 0, includingcheck:test-typecheck.pnpm --filter @objectstack/cli exec vitest run --project unit: 243 files and 3439 tests pass. Theintegrationlayer is left to CI, because this diff touches no spawn entry....@objectstack/lint), narrowed to@objectstack/cli, theos validatedoor the dispatch names. No other importer owes tests, because@objectstack/lint's export surface is byte-unchanged: the new function is module-private.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derives 88 commands, and all 88 exit 0.--ranreconciles 88 derived, 88 run, 0 NOT-MEASURED, with every exit code recorded.PREREQUISITE NOT MET(exit 3):check:skill-examples(noclient-reactdist) andcheck:dual-build-cjs-loads(8 packages with no dist). They passed once those packages were built.5a9292e6f6during the run, and only.github/workflows/ci.ymlchanged among the derivation's inputs. A derivation from a tree at5a9292e6f6, given this diff's 4 paths, yields the same 88 commands.eslint.config.mjs: onlyts,tsx,mts,cts,js,jsx,mjsandcjsglobs are linted, so of this diff's 4 paths the 2.tsfiles are the population. Runningeslint --no-inline-config --format jsonon them gives 2 files, 0 errors and 0 warnings. Invariance: the config enables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis left to CI.b82d990cb9: the changeset and the docs row only; code and tests unchanged since46b40dc845):node scripts/check-adr-0087-registration.mjs --base origin/mainexits 1 with the arm corrected and no marker (signals BREAKING and clause-②-narrowing), and exits 0 with the marker.node scripts/check-changeset-no-major.mjs --base origin/mainexits 0. With this body passed as thepull_requestevent (--event), it also exits 0 and its level axis readsno (narrowing), a BREAKING change that shipsminorduring the launch window. The 40 familiesdispatch-gates.mjsderives for the.mdxpath all exit 0, as do the changeset path'scheck-empty-changeset,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-changeset-deadline-censusandcheck:published-files.Acceptance notes
skills/objectstack-i18n/SKILL.md(Tier H,domain:skills, a separate PR, not edited here). It would need three changes:_actions.{action_name}row of its sub-key table (:164) listsparams.{param_name}andresultDialogonly. It should also listparams.{param}.options.{value},outcomeMessages.{outcome},resultDialog.title/.description/.acknowledgeandresultDialog.fields.{path}.:198-:202) enumerates whattranslation-target-unknownnames. It should add an action's undeclared outcome, an undeclared result-dialog field, and a result dialog the action does not declare.globalActions(:170) should state whatos validatedoes: a key naming a bound action is an error whose message names the object-scoped key to write instead.Clause-②line readsno (narrowing): this diff narrows whatos validateaccepts and widens nothing. Spelled that way,check:adr-0087-registrationasks for a disposition. PR feat(lint): os validate judges every keyed child of an action translation entry against its declaration #21258's landed changeset carries the same bareyesthis PR first copied; that is left to the dispatching seat.