Repository navigation
fix(objectql)!: the registry fold merges validation rules by name; a tenant's same-named copy carries only its message and active, and a changed condition is reported at load, never run - #28445
Conversation
… a tenant's reworded copy of a packaged rule is one rule, run once with the tenant's sentence A validation rule's identity within an object is its name. The fold concatenated `validations`, so a tenant overlay that reworded an extension's rule (a Studio round-trip stores a copy of every served rule) held its copy beside the extension's original and both ran. The fold now merges rules by name across layers, the way fields merge by key. A tenant base's copy of a rule a code layer ships (the owner or an extension) runs as the package declares it, with the copy's `message` and `active`; the copy's own condition never runs. A copy that changes more than those two is named once at error (`validation_rule_copy_changed`) with the prescription, and `SchemaRegistry.tenantRuleCopyRefusal` returns its refusal (`ValidationRuleCopyRefusedError`, INVALID_METADATA, 422) for a body before it is stored. Rules of different names stay additive; between two code layers the later same-named rule replaces the earlier one. `getPackagedObjectBase` merges the packaged rules the same way, and `subtractExtenderContributions` no longer subtracts rules: the by-name merge is idempotent by itself. ADR-0029 D9.2a gains the dated note the ruling asked for. Claude-Session: https://claude.ai/code/session_016WV4d1cS6VY12DcqQYpdvA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV Co-authored-by: Claude <noreply@anthropic.com>
…ling B; the changeset declares Metadata-protocol none at patch The overlay load path only reports a same-named copy that changes a packaged rule's condition or type, and runs the package's rule, so an upgrade never fails on stored data. The ADR-0029 D9.2a note names where the loud refusal sits, and the changeset follows the ruling's `Metadata-protocol: none`, which grades every entry `patch`. Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 20 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 3045c8370f55c5795a9492278a2d2cc91b84903b && git checkout 3045c8370f55c5795a9492278a2d2cc91b84903b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f91ab5a08af7a7c4e5d3f6f89dd22fef67193ba0 e5e62b28c322e16c662339fc3d6931a79a26647a && git checkout -B drift-repro f91ab5a08af7a7c4e5d3f6f89dd22fef67193ba0 && git merge --no-ff e5e62b28c322e16c662339fc3d6931a79a26647a
node scripts/docs-audit/affected-docs.mjs --json f91ab5a08af7a7c4e5d3f6f89dd22fef67193ba0
|
|
triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract-review-claim: |
|
triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract reviewServed-tier: ① Derived judgments
② Rulings cited
③ Narrowing / widening
④ Behaviour / flags
Implemented-by: |
|
domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc 维护者速读
席位意见:契约复审 PASS,两项裁定都已按原文实现。保存时的拒绝放在 #28448,它被本 PR 阻塞,要排在 席位核对ACCEPT · seat
|
Requested by Tesla · project thread
Fixes #27949 (the load-path half of ruling B; the save-door refusal is a follow-up card the seat files,
Blocked-by:this PR).Clause-②: no (narrowing)
Metadata-protocol: none
Ruling-ref: 6545641964 (letter B, maintainer 「同意」 2026-10-31T15:47Z), under ruling 6524229156 (letter C′, maintainer 「同意」 2026-10-30T13:40Z).
维护者速读
name合并,不再把各层的规则数组拼接。租户覆盖层里与包内规则同名的副本只取它的message和active,条件和类型始终用包里的;不同名的规则照旧叠加。加载时发现副本改了条件或类型,只记一条error日志并照包的规则执行,不抛错。ADR-0029 D9.2a 加一段带日期的说明。packages/objectql/src/registry.ts的mergeValidationsByName与withPackagedOwnerRules、reportChangedRuleCopies(只报告不拒绝);docs/adr/0029-…md的 D9.2a 说明。pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/registry-validation-rule-by-name.test.ts,预期 14 个用例全绿。docs/adr/**(Tier H),合约复审通过后请用 os-zhuang 批准。What changes
The fold merges
validationsby name (packages/objectql/src/registry.ts). A validation rule's identity within an object is itsname, somergeObjectDefinitionsno longer concatenates the arrays.mergeValidationsByNamelets a later layer's rule replace every earlier rule of its name, and adds a rule of a new name. Rules of different names stay additive, as ADR-0029 D9.2a protects. Between two code layers, the later layer's same-named rule replaces the earlier one, the way a field does.What a tenant's copy carries. When the fold's base is a tenant overlay (or a runtime-authored owner),
tenantRuleSettingsreads each rule'smessageandactiveby name, first copy wins. The packaged rule runs with those two keys and nothing else from the copy:withPackagedOwnerRulesreplaces a copy of the packaged OWNER's rule with the owner's rule plus the copy'smessage/active, so a later package fix to that rule reaches the tenant.mergeValidationsByName.active: falseon the copy switches the rule off.Load path: report, never refuse (ruling B).
reportChangedRuleCopiescompares each tenant copy with the packaged rule set (packagedRules). The comparison reads omitted keys at the schema defaults, so a parse that stampedevents/priority/severityis not a change. A copy that changes anything beyondmessageandactiveis named once per registry aterror(validation_rule_copy_changed), with its consequence and the ruling's prescription. The fold has already run the package's rule. Nothing throws, so a package upgrade never fails a boot on stored data.Predicate for the save door.
SchemaRegistry.tenantRuleCopyRefusal(name, body)returnsValidationRuleCopyRefusedError(INVALID_METADATA, 422,objectName,ruleName,changedKeyswithtypefirst, the prescription in the message) for a body that changes a packaged rule's condition or type, andnullotherwise. Nothing calls it yet. Wiring it into the metadata save door is the follow-up card. Under B,registerObjectneeds no build-time refusal, and this PR adds none.Idempotence (#8027).
subtractExtenderContributionsno longer subtractsvalidations. The by-name merge already replaces every base copy of an extender's rule name, so a folded base re-folds to itself. Subtracting first would also strip the tenant'smessage/activeoff a reworded copy before the fold reads them.getPackagedObjectBaseserves the packaged rules merged by name the same way.ADR-0029 D9.2a gains a dated note recording both rulings: rules of different names add; a same-named rule merges by name with the condition owned by the package; the loud refusal sits at the save door; the load path only reports.
Changeset
.changeset/27949-validation-rules-merge-by-name.md:@objectstack/objectqlpatch(ruling A:Metadata-protocol: nonegrades every entrypatch), carrying the BREAKING banner and the ADR-0087not-requireddisposition.Tests.
registry-validation-rule-by-name.test.tshas 14 cases in five blocks: a reworded copy is one rule; a changed condition or type is refused by the predicate and never runs at load;active: falsedeactivates; different names add; and re-folding is idempotent, including a legacy row stored while the fold still appended.engine-validation-tenant-rewrite.test.tschanges one expectation. The extension rule is now served and refused once, with the tenant's sentence, on every header (before: the tenant's copy plus the translated original).Tests
All on head
e5e62b28(seat 4's2a499b92merged withmain630b4078, then the ruling-B changeset and ADR alignment).pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2: Test Files 507/507, Tests 9847/9847.pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheck: OK, debt held).12 one-time ablations (each through scripts/ablation-replace.mjs, restored to the HEAD blob, git diff HEAD empty after each)
Run:
pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/registry-validation-rule-by-name.test.ts src/engine-validation-tenant-rewrite.test.ts(18 cases). The suite imports./registry.jsfrom source, so no dist rebuild is involved. Every mutation went red:registry.tsmergeValidationsByNameconcatenates againmessageis not readactiveis not readactive: falsedeactivates)tenantRuleSettingsanswersundefinedwithPackagedOwnerRulesis a no-op (owner copy keeps its own condition)tenantRuleCopyRefusalnever refusespackagedRulesskips extension rulesThe first runs of A05, A06 and A08 were refused by the tool as no-ops (the replacement text was already counted in the file). They were re-anchored with distinct replacement text and re-run, and the numbers above come from those re-runs.
Acceptance notes
tenantRuleCopyRefusalhas no caller in this PR, on purpose: ruling B puts the refusal at the save door (follow-up card behind theprotocol.tsserial queue on [PM seat] domain:server — 🟢 os-tesla · 由 os-tesla 账户的项目负责 #6367).message,activeand_-prefixed loader stamps, so it is stricter than "condition or type": any package-owned key (for examplefields,events,severity) counts as a change. That matches C′'s "everything else is the package's".!from seat 4's commit. The changeset level followsMetadata-protocol: none(patch) under ruling A, and the BREAKING banner stays in its body.Verification
Derived gates: 70 derived, 70 run, 70 exit 0
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackone5e62b28c3derived 70 commands over 5 paths (759 changed lines). Each ran with stdin from/dev/null, its exit captured by redirect before any pipe. All 70 exit 0, after a full workspace build (turbo run build, dts on, excluding docs and@object-ui/*).dispatch-gates --ran:70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED, exit 0.Scoped lint, using the root
lintscript's command overpackages/objectql: exit 0.Full dogfood (owed by the
(narrowing)arm) runs once on this head; its result is in the os-dev report on the card.Tier H by path (
docs/adr/0029-…). It stays a draft until the contract review and the governed approval are on record; the seat lands it.Generated by Claude Code