Skip to content

fix(i18n): a tenant-rewritten validation rule message beats the packaged translation, on the refusal and on /meta - #27543

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-i8f8j5
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-i8f8j5

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Requested by Sales · project thread
Fixes #27221

Clause-②: yes (widening) — the exported scalarOverridesPackagedBase (@objectstack/spec/system) accepts 'message' as a key, and ValidationMessageContext gains the optional packagedRule (reached from EvaluateRulesOptions.messages); corrected by the seat per contract review 6496586363
Metadata-protocol: semantic
Skills-docs: updated here — content/docs/ui/translations.mdx ("An edit beats the packaged catalog": validation rule messages), prose-only commit e4a45ed

维护者速读

  • 改了什么:租户在 overlay 里改写了套件自带校验规则的提示语(规则名不变)后,非默认语言下拒收报错和 /meta 都会被套件翻译盖回去;现在两处都只在提示语仍等于套件原句时才套用翻译,改写过的照原样显示,没改的规则照常翻译。
  • 为什么改:ADR-0029 D9.2a「显式改写胜过套件默认」在规则提示语上两个读取端都没生效,租户给自己用户改的话一换语言就变回厂商译文。
  • 风险与回滚:只收窄「何时套用翻译」,不新增键、不改拒收的 code/field;套件未声明的规则(objectExtensions 带来的)照常翻译。回滚即 revert 本 PR。
  • 先看:packages/spec/src/system/i18n-resolver.ts(translateObjectValidations 的比较)、packages/objectql/src/validation/rule-validator.ts(authoredRuleMessage)、packages/objectql/src/engine-validation-messages.ts(packaged 规则查找)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-validation-tenant-rewrite.test.ts,预期 3 passed:zh-CN/zh/en 三个语言下 /meta 与拒收给出同一句,改写的规则是租户原句。
  • 需要你做的:无,席位落地(本 PR 触 packages/spec,欠一次 head 上的契约复审)。

What changes

ADR-0029 D9.2a, applied to validation rule messages on both of their readers, with one comparison.

  • /meta (translateObjectValidations, packages/spec/src/system/i18n-resolver.ts). The overlay of validations[].message from objects.OBJECT._validations.RULE.message now applies only while the served message still equals the message of the packaged rule of the same name in TranslateDocumentOptions.packagedBase (a conditional branch by its own name). The test is valueOverridesPackagedBase, the predicate the object's scalars, embedded list views and field descriptions already use. The docblock's "No packaged-base comparison" section, which recorded the deliberate deviation of fix(spec): translateObject serves validations[].message in the reader's language #27202, is replaced.
  • The refusal (authoredRuleMessage, packages/objectql/src/validation/rule-validator.ts). The same rule, through the exported scalarOverridesPackagedBase, whose key union gains 'message'. That widening is the only change to a @objectstack/spec/system export: a parameter type accepts one more literal; the behaviour for the three existing keys is unchanged, and no generated artifact moves (api-surface/system.json records names, api-surface-signatures.json records only the define* signatures).
  • The packaged rule (packages/objectql/src/engine-validation-messages.ts, record-validator.ts, engine.ts). ValidationMessageContext gains an optional packagedRule(ruleName) lookup. validationMessageContext fills it from SchemaRegistry.getPackagedObjectOwner(name).definition, the same body /meta hands its translator through getPackagedObjectBase; the index is built on the first lookup. engine.ts changes only the host literal of ObjectQL#validationMessageContext (one registry getter), added to the claim's file surface by the seat (6495205921). No packaged owner, or a registry that cannot answer, leaves the lookup absent, and the catalog applies as before.

One rule, two readers: /meta and the refusal of the same write still show one sentence for one rule and locale, pinned together in engine-validation-tenant-rewrite.test.ts.

Why

A tenant who reworded a packaged rule's message for their own users saw it reverted to the vendor's translation as soon as a user's locale had a bundle entry, on the server's refusal and on the served form. The refusal half predates #27202; the /meta half followed from #27202 keeping the two readers identical (triage 6468439288: "One precedence rule, used by both readers").

Tests

Red first (commit 7976cc1a, tests only, on origin/main 57c51aab4):

Final head (cb1b6888, origin/main 6892888e merged in):

  • New and changed pins: i18n-resolver.object-validations.test.ts (four D9.2a cases: rewrite served as written, untouched rules translated, a rule the base does not declare keeps its own entry, no base means catalog as before, through translateMetadataDocument('object') too); rule-validator.test.ts (five evaluator cases, a conditional branch included); engine-validation-tenant-rewrite.test.ts (one real SchemaRegistry with a packaged owner, a tenant overlay and an objectExtensions contributor, the refusal of real writes beside translateObject with getPackagedObjectBase as packagedBase, for zh-CN, zh and en; plus a no-overlay control).
  • pnpm --filter @objectstack/spec test under the verify lock: VERDICT command-exit 0, Test Files 751 passed (751), Tests 22332 passed, 1 todo.
  • pnpm --filter @objectstack/objectql test under the verify lock: VERDICT command-exit 0, Test Files 485 passed (485), Tests 9109 passed (9109).
  • pnpm --filter @objectstack/spec typecheck and pnpm --filter @objectstack/objectql typecheck (with its check:test-typecheck): both VERDICT command-exit 0.

Ablation (negative pins). Each run uses node scripts/ablation-replace.mjs on the committed head e7578942. Every mutation was proven on disk (anchor 1 to 0, blob changed), and every restore matched the HEAD blob byte for byte. All three subjects resolve through src (relative imports), so no dist preflight applies:

  1. rule-validator.ts: scalarOverridesPackagedBase(messages.packagedRule?.(rule.name), 'message', rule.message) replaced by false. 3 failed / 193 passed (196). The two evaluator rewrite cases went red, and so did the engine case, where only the refusal half differed from the served half.
  2. i18n-resolver.ts: the valueOverridesPackagedBase(packaged.get(rule.name), 'message', rule.message) line deleted. 2 failed / 14 passed (16), the rewrite cases.
  3. engine.ts: the host literal's get registry() line deleted. 1 failed / 2 passed (3), the engine case. This proves the wiring, not only the predicate.

The deviation in Acceptance notes is measured too. The final tests were run against the first implementation (1a6b3305), which counted a rule absent from the base as authored. engine-validation-tenant-rewrite.test.ts failed 2 of 3. The extension package's rule answered its English sentence under zh-CN and zh, both in the overlay group and in the no-overlay control.

Acceptance notes

  • Deviation from the retriage's suggested shape (6487611040), measured. The retriage read "A tenant rule with no packaged counterpart counts as authored." This PR does not do that: a rule the packaged owner's declaration does not carry has no packaged message to diverge from, so the catalog applies, as it does with no base at all (the predicate's own first edge). Reason: objects.OBJECT._validations.RULE also addresses rules an objectExtensions entry folds onto the object (content/docs/protocol/kernel/i18n-standard.mdx), the registry concatenates those rules into validations, and both getPackagedObjectBase and getPackagedObjectOwner answer the OWNER's declaration only. Counting an absent rule as authored served every extension package's translated rule in the source language, on both readers, with no tenant overlay at all. Measured: the engine test's extension rule, run against the first implementation (1a6b3305), went red in both the overlay group and the no-overlay control (zh-CN answered the English sentence); green on the final head. Unlike a list view, a rule is translated under its own name, which is why the list-view convention ("a view the base does not carry counts as diverged") is not copied here.
  • Not covered, outside the declared surface: a tenant who rewrites the message of a rule that an objectExtensions entry contributed still gets that package's translation (the catalog applies, as before this PR; not a regression). Covering it needs the packaged base that /meta receives to include code extenders' rules, i.e. a change to where @objectstack/rest sources packagedBase (packagedObjectBaseOf / getPackagedObjectBase), and the same source on the refusal side so the two readers stay identical. Left to the seat to file or not.
  • One predicate; the lookup is walked twice. The precedence test is valueOverridesPackagedBase on both readers (objectql reaches it through scalarOverridesPackagedBase). The by-name walk over the packaged validations (branches included, the same depth bound of 10, first name wins) exists once per package: packagedRulesByName in i18n-resolver.ts and packagedRuleLookup in engine-validation-messages.ts. Sharing it would need a new @objectstack/spec/system export, which regenerates packages/spec/api-surface/system.json and export-origins/system.json, outside "nothing else under packages/spec".
  • ObjectQL#validationMessageContext's declared return type in engine.ts still names locale / translate / objectName only; the object it returns carries packagedRule too and reaches the validators unchanged. Widening the declaration is a second engine.ts hunk, outside the approved host-literal edit.
  • In-flight intersection (grant 6488299876). The file lists of the 22 open non-mailbox PRs read at 2026-10-29T11:27Z: none touches i18n-resolver.ts, rule-validator.ts, record-validator.ts or engine-validation-messages.ts; only fix(objectql): extend the credential-column guard from aggregates to filter and sort keys #27477 touches engine.ts (hunks L13315–20160 per the seat; this PR's hunk is at L9548), and git merge-tree --write-tree of this head with fix(objectql): extend the credential-column guard from aggregates to filter and sort keys #27477's head 5eb62f09 is clean. The three pm:mailbox PRs were not read; the Version Packages PR chore: version packages #26517 hits none of these files on its first 100. Since then origin/main gained four commits (to d4ff1a02, read at 12:05Z), none touching this PR's nine files; git merge-tree --write-tree of this head with it is clean.
  • Branch name. claude/project-thread-i8f8j5 is the card thread's harness-pinned branch and names no issue; the claim (6495130451) is the identity record.
  • Contract review owed on this PR's head before the maintainer is asked to approve (it touches packages/spec, pm-dispatch(contract-review): a seat sends a PR to the maintainer for approval only after a contract PASS on the current head, and a PR that landed still owing one is re-reviewed after landing, with each finding filed as a new card #27345).

Verification

Derived local subset on the final head cb1b6888: node scripts/pm/dispatch-gates.mjs --commands, no paths, against merge base 6892888e4, 9 paths, 589 changed lines. Every one of the 85 commands was run and its exit code captured before any pipe. The reconciliation --ran: "85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED (a DERIVED zero — all 85 recorded an exit code and none of them is 3)". Two first answered exit 3 (a prerequisite, not a verdict) and were re-run green:

  • check-plugin-teardown-shape.mjs --self-test passed after fetching its pinned fixture commit: 48 cases.
  • check:dual-build-cjs-loads passed after a full turbo run build: 115/115 tasks.

Also run:

  • pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date". No artifact under packages/spec moves.
  • check:dev-prereqs and check:skill-frame-freshness (local-only): exit 0.

Repo-wide lint and the CI-owned families are read from this PR's check-runs.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (The change is inside the existing translator module; no schema moves.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (Repo-wide lint is CI's run.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (check:generated reports none stale.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc

… /meta (red)

ADR-0029 D9.2a: under a non-default locale the packaged translation of a
validation rule message must not replace a tenant's rewrite of it. Red
first, on both readers: translateObject's validations overlay, the rule
evaluator's authored message, and the two together off one registry.

Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc
Co-authored-by: Claude <noreply@anthropic.com>
…tion on both readers

ADR-0029 D9.2a. translateObjectValidations (the /meta overlay) and the
rule evaluator's authoredRuleMessage (the refusal) now apply the catalog
only while the served message still equals the packaged rule's of the
same name, judged by the one predicate: valueOverridesPackagedBase, which
objectql reaches through the exported scalarOverridesPackagedBase (its key
union gains 'message'). The engine's validation message context carries
the packaged owner's rule lookup.

Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc
Co-authored-by: Claude <noreply@anthropic.com>
…lation

A rule an objectExtensions entry folds onto a packaged object is absent from
the owner's declaration by construction and is translated under its own
name. Counting it as diverged served it untranslated on both readers. A rule
with no packaged counterpart now has nothing to diverge from, as with no base
at all, and the catalog applies.

Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:system tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 11 documentable anchor(s).

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

  • content/docs/api/wire-format.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/concepts/metadata-driven.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/data-modeling/index.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/data-modeling/object-extensions.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/data-modeling/objects.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/data-modeling/schema-design.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/getting-started/build-with-claude-code.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/getting-started/examples.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/getting-started/quick-start.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/getting-started/your-first-project.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/protocol/kernel/i18n-standard.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/protocol/kernel/plugin-spec.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/protocol/objectql/schema.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
  • content/docs/ui/translations.mdx (via pluralLabel (literal, a string literal in scalarOverridesPackagedBase))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 78 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 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 — 168 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 d4ff1a025fa4568505bfbc66a0f8cbeb3bab06ff → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4ccb3bca139066cbf1d91b1a100dff4d4187bffe — the merge of head 3e8364560d176c6cae02faa7db982154b88fd54d into base d4ff1a025fa4568505bfbc66a0f8cbeb3bab06ff, 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 4ccb3bca139066cbf1d91b1a100dff4d4187bffe && git checkout 4ccb3bca139066cbf1d91b1a100dff4d4187bffe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4ff1a025fa4568505bfbc66a0f8cbeb3bab06ff 3e8364560d176c6cae02faa7db982154b88fd54d && git checkout -B drift-repro d4ff1a025fa4568505bfbc66a0f8cbeb3bab06ff && git merge --no-ff 3e8364560d176c6cae02faa7db982154b88fd54d

node scripts/docs-audit/affected-docs.mjs --json d4ff1a025fa4568505bfbc66a0f8cbeb3bab06ff

⚠️ 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 d4ff1a025fa4568505bfbc66a0f8cbeb3bab06ff → 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

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head cb1b688 · 2026-10-29T12:11Z

Face: published schema (packages/spec/src/system/i18n-resolver.ts, non-test). A CONTRACT_REVIEW_TIER reviewer independent of the drafter runs now; the record lands on this PR. It also judges the Clause-②: line against the widened key union of the exported scalarOverridesPackagedBase, as the claim amendment 6495205921 on #27221 asks.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cb1b68882526450eb9047a751b02406fca9ed64e
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts + packages/objectql/src/engine-validation-messages.ts, packages/objectql/src/validation/rule-validator.ts, packages/objectql/src/validation/record-validator.ts, packages/objectql/src/engine.ts (the ObjectQL#validationMessageContext host literal), packages/objectql/src/registry.ts (getPackagedObjectOwner), packages/metadata-protocol/src/protocol.ts (getPackagedObjectBase), packages/rest/src/meta-item-read-gate.ts (packagedObjectBaseOf, translateMetaList, translateMetaItem), packages/rest/src/rest-server.ts, packages/rest/src/form-routes.ts, packages/metadata-core/src/code-artifact-provenance.ts, packages/spec/package.json (exports), packages/spec/api-surface/system.json, packages/objectql/src/index.ts, docs/adr/0029 D9.2a, .changeset/27221-tenant-rewritten-rule-message-wins.md

Written 2026-10-29T12:23Z. Read-only: card #27221 (body and all 7 comments through 6496338780), PR #27543 body and file list, git diff origin/main...cb1b6888 in a detached checkout of origin/main at d4ff1a02, the head's check-runs. Dispatched by the seat; the seat's own conclusions were not an input.

Head and check-runs. The PR head moved after dispatch: e4a45ed (pushed 2026-10-29T12:13:54Z) adds 13 lines to content/docs/ui/translations.mdx and nothing else (git diff --stat cb1b6888 e4a45ed8: 1 file). That is a prose-only move, so this record can keep governing under contract-review.md's prose-face exception once the PR body carries its Regen-provenance: line; the body read at 2026-10-29T12:19:41Z does not carry one yet. Check-runs on cb1b688 read at 2026-10-29T12:13Z: 41 runs, none failed, Lint ESLint / PM dispatch-gates / Type Check source gates / Check Changeset / Governed Surface Queue Guard / Spec property liveness success, Build Core and all 8 Test Core shards in progress. Read again at 2026-10-29T12:19Z: both workflow runs (Lint & Type Check 40870833931, CI 40870833802) completed/cancelled by the newer push; the three failure conclusions (Lint & Repo Gates, TypeScript Type Check, Test UI) are the "Verify every lane succeeded" aggregate steps over cancelled lanes, not a verdict on the diff. So this head has no completed verdict for the required set; landing condition ② (every check green) is read on the landing head e4a45ed, where at 2026-10-29T12:20Z 17 runs were success, 18 in progress, 0 failed.

① Derived judgments

  1. scalarOverridesPackagedBase key union gains 'message' — a public-surface widening of @objectstack/spec. The Clause-②: no declaration is WRONG. Must-fix. The function is exported from packages/spec/src/system/i18n-resolver.ts, re-exported by packages/spec/src/system/index.ts:126 (export * from './i18n-resolver'), which is the ./system entry of packages/spec/package.json exports (types ./dist/system/index.d.mts), and it is listed in packages/spec/api-surface/system.json:892. Under contract-review.md (已发布面以包的 exports 映射为准) it is published surface, reachable from the entry type graph, and a parameter type that accepts one more literal is a widening of the accept set (clause2-line.mjs: 「本卡放宽接受集或扩大公开面吗」; dispatch-gates: a card that WIDENS a published accept set or the public surface is Clause-②: yes). That the generated artifacts do not move (api-surface/system.json records names, api-surface-signatures.json records define* only) is exactly why the declaration line is the only carrier of this fact. Right spelling, on the PR body and the changeset body alike: Clause-②: yes (widening). landing-operations.md: 「错误的 no 是可审计的假申报」.
  2. A second widening, same conclusion: ValidationMessageContext.packagedRule? (record-validator.ts) is reached from EvaluateRulesOptions.messages?: ValidationMessageContext (rule-validator.ts:510), and EvaluateRulesOptions is export typed from packages/objectql/src/index.ts:495, the root entry of @objectstack/objectql. An optional member added to a published options type is a widening of that package's surface too. The changeset body already describes both changes as surface changes; only the declaration line contradicts it.
  3. Can the lane still carry it under the retriage's cross-domain grant (6488299876)? Yes. The grant turned on the file surface (i18n-resolver.ts and its tests, nothing else under packages/spec) and on keeping both readers in one PR, not on the seat's no reading; it ordered 「reuse valueOverridesPackagedBase」, whose only exported wrapper is scalarOverridesPackagedBase, so the one-literal widening is the grant's own consequence, confined to the declared surface. Amendment 6495205921 said this review judges the arm, which is what happens here. The correction is two prose lines (PR body, changeset), i.e. a prose-only head, delta-reviewed by this reviewer. The seat should post the corrected reading on the card thread so the triage record does not stand on no.
  4. Accept-set change on the metadata protocol: semantic, right. No key is added or refused; an authored validations[].message in an overlay changes meaning (it now wins over the catalog in every locale). Rules the tenant left alone, rules the owner does not declare, and objects with no packaged owner behave as before — pinned in both test files.
  5. No regenerated artifact under packages/spec moves — right. Confirmed from the file list (no api-surface/**, export-origins/**, JSON schema) and from the artifacts' recorded shape.
  6. One predicate, two readers — right. /meta judges by valueOverridesPackagedBase(packaged.get(rule.name), 'message', rule.message) (i18n-resolver.ts); the refusal by scalarOverridesPackagedBase(messages.packagedRule?.(rule.name), 'message', rule.message) (rule-validator.ts), which is the same function body. Both compare against the same body: /meta receives packagedObjectBaseOf(p, 'object', name) → protocol getPackagedObjectBase → registry.getPackagedObjectOwner(name)?.definition (protocol.ts:8947-8957); the refusal reads registry.getPackagedObjectOwner(objectName)?.definition directly (engine-validation-messages.ts packagedRuleLookup). One accessor, one discriminator (isCodeArtifactBody), so the two readers cannot disagree on whether a base exists.

② Semver level

  • Changeset: '@objectstack/spec': minor, '@objectstack/objectql': minor, with Metadata-protocol: semantic. The level follows the kind alone (AGENTS.md Post-Task Checklist 3): a kind other than none takes at least minor on a package the diff moves; both packages move. minor ×2 is right, and stays right after the Clause-② correction (a widening ships patch only when the kind is none).
  • Clause-②: line: wrong on both carriers (①.1-2). Must read yes (widening). No (narrowing) arm, so no BREAKING banner, no ADR-0087 disposition, no dogfood battery is owed.
  • Skills-docs: third line present (kind ≠ none), reading follow-up owed — content/docs/ui/translations.mdx. Head e4a45ed now writes that section. updated here requires the package's evals to exercise it as well; whether the line becomes updated here or follow-up #N is the seat's call at ACCEPT, and the line must not stay owed — FILES past ACCEPT.

③ Boundary flags

Report 6496263463: open_questions: []. Each dev flag and out_of_scope_findings entry:

  1. Measured deviation from the retriage's suggested shape (6487611040: "a tenant rule with no packaged counterpart counts as authored") — judged RIGHT, no fix. The binding rulings are triage 6468439288 (one precedence rule, both readers) and ADR-0029 D9.2a (comparison against the packaged owner ALONE; conservative edges mean "no opinion"). An objectExtensions rule is absent from the owner's declaration by construction (mergeObjectDefinitions concatenates validations, registry.ts:560), and its bundle entry under objects.OBJECT._validations.RULE.message is that rule's packaged default. Counting it as authored would serve every extension package's rule untranslated with no tenant overlay at all — the dev measured this against 1a6b330 (2 of 3 engine cases red) and the final head pins the opposite. The sentence in 6487611040 was the seat's shape reading, not a ruling. Note the asymmetry with translateObject's field description (a field absent from the base counts as diverged via packagedObjectField returning {}) and with translateObjectListViews: deliberate, documented in the docblock and in the docs head, and right for rules because a rule is translated under its own name.
  2. Uncovered: a tenant who rewrites the message of a rule an objectExtensions entry contributed still gets that package's translation on both readers — ESCALATED. Not a regression (the catalog applied before this PR), both readers agree, and the fix sits where packagedBase is sourced (packagedObjectBaseOf / getPackagedObjectBase, owner only) plus the same source in packagedRuleLookup. It is a contract violation (D9.2a) with a named location, so by Prime Directive 10 it is a card, not a note: the seat files it (dedupe words in the report). Should-fix for the lane, not a must-fix on this PR.
  3. Skills-docs follow-up owed — ANSWERED by head e4a45ed (prose-only, content/docs/ui/translations.mdx +13, the "An edit beats the packaged catalog" section now names validation rule messages and the extension-rule exception). Two carriers still to settle before ACCEPT: the PR body's Skills-docs: value and a Regen-provenance: line naming e4a45ed so this record carries (contract-review.md 例外).
  4. ObjectQL#validationMessageContext declared return type omits packagedRule (engine.ts ~L9545) — SHOULD-FIX. The object the method returns carries packagedRule and reaches the validators unchanged, so behaviour is right, but the declaration names locale / translate / objectName only while the moved body's own return type is ValidationMessageContext & { objectName: string }. One-line widening of the declaration; outside the approved host-literal hunk, so it goes with the seat's next amendment or a follow-up card.
  5. The by-name walk exists once per package (packagedRulesByName, packagedRuleLookup) — accepted, no fix. A shared helper would be a new @objectstack/spec/system export, barred by the grant. The depth bound is pinned to the same constant value (10) in both, and both walk then then otherwise, first name wins, wrapper never indexed.
  6. PR author is objectstack-fleet[bot] (relay write): no off-relay note owed.

④ Design judgment

packages/spec/src/system/i18n-resolver.ts, judged against the code it cites and the accepted scenario (the card's ruling: a tenant-rewritten validation rule message wins over the packaged catalog translation, on /meta and on the refusal, one sentence per rule and locale):

  • The accepted scenario holds on both readers. Packaged rule and tenant overlay rule with the same name, message reworded: /meta returns raw before the locale walk; the refusal returns rule.message before objectValidationMessageKey. Pinned end to end in engine-validation-tenant-rewrite.test.ts on one real SchemaRegistry (packaged owner, overlay layer, extend contributor), translateObject fed protocol.getPackagedObjectBase(OBJ) exactly as packagedObjectBaseOf feeds it, for zh-CN, zh and en, with a no-overlay control. Ablation evidence in the PR body is consistent with the three new code lines.
  • A rule the tenant did not rewrite is byte-equal to the owner's rule → predicate false → the catalog applies through the unchanged localeChain walk on /meta and the unchanged translate call on the refusal. Pinned in both files.
  • Locale fallback is untouched for a non-diverged rule. A diverged rule is served as written in EVERY locale, the default included, which is what D9.2a orders (and what the object scalars already do). A tenant who types the packaged translation itself as the rewrite is served that string to every reader — the ruled behaviour, not a defect.
  • Missing packaged base: packagedRulesByName(undefined) → empty map → packaged.get(name) undefined → valueOverridesPackagedBase's first edge → catalog applies, as before The i18n catalog is resolved ahead of the document, so a tenant's own Studio rename never reaches either /meta/object read — and neither do extension scalar overrides #8284. A base with no validations behaves the same (pinned). On the refusal, packagedRule is simply absent from the context when getPackagedObjectOwner answers nothing or throws, and the catalog applies (pinned).
  • Extension rules: absent from the owner's validations → nothing to diverge from → translated under their own name on both readers (pinned, with the overlay present and absent).
  • conditional: the wrapper is not indexed, branches are keyed by their own name to depth 10, DFS then then otherwise, first name wins — identical in packagedRulesByName and packagedRuleLookup, and identical to how translateRule and the evaluator's checkConditional address a branch. Pinned on both readers.
  • Other translateObject callers: form-routes.ts:317 (public form) passes packagedBase but its objectSchema carries { name, label, fields } only, so translateObjectValidations never sees a rule there; formula-option-labels.ts:150 translates { name, fields }. No caller is left serving a divergent sentence.
  • Signature change opts: ResolveOptions → TranslateDocumentOptions on the private translateObjectValidations: its one call site (translateObject, line ~4221) already passes a TranslateDocumentOptions; no public type moves.
  • scalarOverridesPackagedBase widening: behaviour for the three existing keys is unchanged (the body delegates to valueOverridesPackagedBase as before); the three conservative edges hold for 'message' too. The docblock's new section is accurate. The ONLY defect here is the declaration that this is not a surface change (①.1).

Findings:

  • Must-fix: Clause-②: no on the PR body and in .changeset/27221-tenant-rewritten-rule-message-wins.md → Clause-②: yes (widening) (①.1-2). Prose-only; this reviewer delta-reviews the correcting head.
  • Should-fix: ObjectQL#validationMessageContext declared return type (③.4).
  • Should-fix (lane, filed as a card by the seat): tenant rewrite of an extension-contributed rule still loses to the catalog on both readers (③.2).
  • Before ACCEPT: Regen-provenance: line naming the prose-only head(s) after cb1b688, and the Skills-docs: value settled (③.3); landing condition ② read on the landing head.

Implemented-by: claude/project-thread-i8f8j5
Reviewed-by: subagent of session_01L6bhgXcN9HaZYSEUdJWEjw (domain:server#1 contract reviewer)

VERDICT: FAIL


Generated by Claude Code

… body returns; changeset declares Clause-② yes (widening)

The contract review of record found the declaration short: the exported
scalarOverridesPackagedBase key union and EvaluateRulesOptions.messages'
packagedRule both widen a published surface.

Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head 3e83645 · 2026-10-29T12:54Z

Delta review of the FAIL record 6496586363 (on cb1b688) by the same reviewer: the must-fix (declaration) and the should-fix (declared return type) landed in 3e83645; e4a45ed is prose-only. The PR body was corrected by the seat to Clause-②: yes (widening).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3e8364560d176c6cae02faa7db982154b88fd54d
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts + packages/objectql/src/engine.ts (ObjectQL#validationMessageContext declaration and host literal), .changeset/27221-tenant-rewritten-rule-message-wins.md, content/docs/ui/translations.mdx, packages/objectql/src/engine-validation-messages.ts, packages/objectql/src/validation/record-validator.ts, packages/objectql/src/validation/rule-validator.ts, packages/spec/src/system/index.ts, packages/objectql/src/index.ts

Delta review, written 2026-10-29T12:56Z, by the same reviewer as the FAIL record 6496586363 on cb1b688 (contract-review.md: a commit that only lands findings gets a delta review by the same reviewer). Inputs: git diff cb1b6888 3e836456 and git diff origin/main...3e836456 in the detached origin/main checkout; the PR body read fresh at 2026-10-29T12:55:03Z; card amendment 6496644501; claim 6497232414 (head 3e83645); the check-runs on 3e83645. Read-only; nothing built or run.

What moved since cb1b688 (3 files, +16/-3): e4a45ed — content/docs/ui/translations.mdx +13, prose only; 3e83645 — the changeset's Clause-②: line and 4 lines in packages/objectql/src/engine.ts (one type import, one return-type annotation). packages/spec/** is byte-identical to the reviewed head; the diff against origin/main is the reviewed 9 files plus the docs page. The two commits land the findings of 6496586363 and nothing else, so ①-④ of that record stand for everything not named below.

Check-runs on 3e83645, read 2026-10-29T12:55Z: 50 runs, 41 success, 9 skipped, 0 failure, 0 in progress — Lint & Repo Gates, TypeScript Type Check, Build Core, Test Core, Governed Surface Queue Guard, Check Changeset all success.

① Derived judgments

  1. Must-fix closed on BOTH carriers. Changeset line 8 now reads Clause-②: yes (widening) (diff -Clause-②: no / +Clause-②: yes (widening)). PR body line 7 reads Clause-②: yes (widening) — … with the two widenings named (scalarOverridesPackagedBase key 'message'; ValidationMessageContext.packagedRule? via EvaluateRulesOptions.messages), the same reading as record 6496586363 ①.1-2 and card amendment 6496644501. Metadata-protocol: semantic unchanged on both carriers — right. The widening arm reads as not breaking (no banner, no ADR-0087 disposition owed); Check Changeset is green on this head.
  2. The card thread's declaration is corrected too (6496644501: Clause-②: yes (widening), lane kept under grant 6488299876, engine.ts declaration added to the file surface, the extension-rule case to be filed through the filing gate). The surface actually touched by 3e83645 is inside that widened surface.
  3. Accept-set and public-surface facts are otherwise unchanged: no new export, no regenerated artifact moves, the packagedRule member and the 'message' key are the same two widenings already judged.

② Semver level

Unchanged and right: '@objectstack/spec': minor, '@objectstack/objectql': minor on kind semantic; Clause-②: yes (widening) does not move the level (it would ship patch only on kind none). Skills-docs: updated here — content/docs/ui/translations.mdx (…), prose-only commit e4a45ed8 is a valid third-line value for a kind other than none; the page is in the diff, so the owed — FILES state no longer stands.

③ Boundary flags

  1. Should-fix (declared return type) — landed, RIGHT. ObjectQL#validationMessageContext now returns ValidationMessageContext & { objectName: string } (engine.ts ~L9545), the exact return type of the moved body validationMessageContext in engine-validation-messages.ts, so the declaration and the body can no longer drift; ValidationMessageContext is imported as a type beside the two existing type imports from record-validator.js. The intersection keeps objectName required (the member is optional on ValidationMessageContext), so every call site (evaluateValidationRules messages, envelopeDriverWriteRefusal, engine-validate.ts, engine-delete-cascade.ts) sees a type at least as narrow as before, and TypeScript Type Check is green on this head. No runtime line moved.
  2. Extension-rule rewrite (record ③.2) — the seat's amendment 6496644501 routes it to the card thread's filing gate; still owed as a card, not a change to this PR. Nothing further for this record.
  3. Regen-provenance: — not owed on this head: 3e83645 moves code (engine.ts), so this record on 3e83645 is the governing record, not a carried one.
  4. Prose commit vs code (e4a45ed, translations.mdx L226-238) — every sentence checked against the tree: "the error a rejected write returns, and the rule served with the object" = authoredRuleMessage and translateObjectValidations, right; "compared with the message of the rule of the same name in the owning package's declaration" = getPackagedObjectOwner(name).definition, the body both readers compare against, right; "a conditional rule's then / otherwise branch by its own name" = both by-name walks index branches, never the wrapper, right; "gets that sentence as written in every locale; the rules it left alone are still translated, and both places always show the same sentence" = the two-reader pin for zh-CN / zh / en, right; "Unlike a widget or a bulk action, a rule the owning package does not declare (one an object extension adds, or one the organization added) is still translated under its own name, objects.OBJECT._validations.RULE.message (the page spells the two placeholders in angle brackets): there is no shipped message for it to differ from" = the first edge of valueOverridesPackagedBase on an empty lookup, and the page's own bullet above it says a widget or bulk action the package never shipped counts as edited, so the contrast is accurate. One phrase to read carefully, not a defect: "(/meta, and the forms built from it)" — the public form route's own payload carries { name, label, fields } and no validations, so the rule reaches a form only through the object document a client builds from; the sentence says exactly that.
  5. PR author objectstack-fleet[bot] (relay); the claim for this head is the seat's (6497232414), posted before this record.

④ Design judgment

packages/spec/src/system/i18n-resolver.ts is byte-identical to the head judged in 6496586363 ④; that judgment stands (one predicate on both readers, same base through getPackagedObjectOwner, untouched rules translated, extension rules translated under their own name, conditional branches by name to depth 10, no caller left serving a divergent sentence). The only code that moved, the engine.ts return-type annotation, makes the declared contract equal to the body's and introduces no behaviour.

No must-fix on this head. The should-fix left open for the lane is the extension-rule rewrite card (③.2).

Implemented-by: claude/project-thread-i8f8j5
Reviewed-by: subagent of session_01L6bhgXcN9HaZYSEUdJWEjw (domain:server#1 contract reviewer)

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

维护者速读(席位终稿)

  • 改了什么:租户在 overlay 里改写了套件自带校验规则的提示语(规则名不变)后,非默认语言下,拒收报错和 /meta(含表单)不再被套件翻译盖回去。两处只在提示语仍等于套件原句时才套用翻译,改写过的照原样显示,没改的规则照常翻译。
  • 为什么改:i18n: a tenant-rewritten validation rule message loses to the packaged translation under a non-default locale, on both the refusal and /meta — apply ADR-0029 D9.2a to both readers #27221,ADR-0029 D9.2a「显式改写胜过套件默认」在规则提示语上两个读取端都没生效,租户给自己用户改的话一换语言就变回厂商译文。
  • 风险与回滚:中低。spec 导出的 scalarOverridesPackagedBase 多接受一个键 'message',objectql 的 ValidationMessageContext 多一个可选成员,两处都属放宽(Clause-② yes),不破坏现有调用。回滚就是还原这一个 PR。
  • 先看:packages/spec/src/system/i18n-resolver.ts 的 translateObjectValidations,以及 packages/objectql/src/engine-validation-messages.ts 新增的 packagedRule。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-validation-tenant-rewrite.test.ts,预期全部通过。
  • 需要你做的:无。Tier S,契约复核 PASS 在案(6497296996),席位落地。

席位意见:接受。首轮复核只挑出申报写错(应为 Clause-② yes),已在两个载体上改正,增量复核 PASS;设计判断在两个读取端都成立。

席位核对 — ACCEPT · domain:server#1 · head 3e83645 · 2026-10-29T12:58Z

  • Shape: one predicate for both readers. /meta's translateObjectValidations compares each rule's message against the same-named rule in the packaged base (valueOverridesPackagedBase); the refusal path's authoredRuleMessage uses the exported scalarOverridesPackagedBase (key union + 'message'). ValidationMessageContext gains optional packagedRule, built in engine-validation-messages.ts from getPackagedObjectOwner(name).definition; engine.ts changes only the host literal and the declared return type. Generated output unchanged.
  • Premise: the claim 6495130451 with amendments 6495205921, 6496338780, 6496644501 holds; Fixes #27221; retriage cross-domain grant 6488299876.
  • Gates: 50 check-runs on this head: 41 success, 9 skipped, 0 failed, 0 pending. check-expected-skips --pr 27543 (fresh origin/main): VERDICT … OK — 9 skipped check-run(s), every one in main's roster (exit 0). mergeable_state: clean. Dev: red-first (spec 3 red, objectql 4 red), three ablation legs each red and restored, dispatch-gates --ran 108/108, 0 NOT-MEASURED.
  • Contract review: owed (published-schema face, packages/spec/src/system/i18n-resolver.ts), served: FAIL 6496586363 on cb1b6888 (one must-fix: the Clause-② declaration) → delta PASS 6497296996 on this head, same reviewer, CONTRACT_REVIEW_TIER.
  • Prose read against the diff: content/docs/ui/translations.mdx +13 (both readers, owner-declaration comparison, conditional branches by their own name, untouched rules still translated, extension or organization-added rules translated under their own key) matches the code, as the delta record ③ found. Changeset: minor ×2, kind semantic, Clause-②: yes (widening).
  • Accepted deviations: from the retriage's "no packaged counterpart counts as authored": rules the suite owner did not declare are translated as before (measured red on 1a6b330 otherwise); judged right by the review against ADR-0029 D9.2a. The by-name walk is duplicated on both sides (same depth bound 10); a shared export is barred by the grant.
  • Declaration: Clause-②: yes (widening) (corrected by the seat on the body, by the thread on the changeset), Metadata-protocol: semantic, Skills-docs: updated here — content/docs/ui/translations.mdx.
  • Out of scope: a tenant rewrite of an objectExtensions-contributed rule still loses to that package's translation on both readers (the fix is where packagedBase is sourced, owner only, plus packagedRuleLookup). The card thread files it through the filing gate (6496644501).
  • Landing: Tier S, NOT governed, 606 changed lines ≤ 3000 → pr_ready + automerge_enable via the relay now.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 12:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 12:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 556f323 Oct 29, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-i8f8j5 branch October 29, 2026 13:20
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 protocol:system size/l tests tooling

Projects

None yet

1 participant