Skip to content

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

Merged
os-zhuang merged 3 commits into
mainfrom
claude/project-thread-9ii7md
Oct 31, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/project-thread-9ii7md

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 加一段带日期的说明。
  • 为什么改:租户在 Studio 里改写扩展包某条规则的提示语后,旧折叠会让两条同名规则同时生效,被拒的写入会报两句话;同时租户复制的规则会冻结住,包里后来的修复到不了租户。
  • 风险与回滚:改了条件的租户副本不再执行自己的条件,改由包的条件判定(启动日志点名该规则);升级不会因存量数据失败。没有任何键的增删。回滚即 revert 本 PR。
  • 先看: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 个用例全绿。
  • 需要你做的:本 PR 改了 docs/adr/**(Tier H),合约复审通过后请用 os-zhuang 批准。

What changes

The fold merges validations by name (packages/objectql/src/registry.ts). A validation rule's identity within an object is its name, so mergeObjectDefinitions no longer concatenates the arrays. mergeValidationsByName lets 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), tenantRuleSettings reads each rule's message and active by name, first copy wins. The packaged rule runs with those two keys and nothing else from the copy:

  • withPackagedOwnerRules replaces a copy of the packaged OWNER's rule with the owner's rule plus the copy's message / active, so a later package fix to that rule reaches the tenant.
  • An extension's rule is merged the same way through mergeValidationsByName.
  • active: false on the copy switches the rule off.
  • A rule the overlay leaves out stays out, as D9.2's replace has always meant.

Load path: report, never refuse (ruling B). reportChangedRuleCopies compares each tenant copy with the packaged rule set (packagedRules). The comparison reads omitted keys at the schema defaults, so a parse that stamped events / priority / severity is not a change. A copy that changes anything beyond message and active is named once per registry at error (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) returns ValidationRuleCopyRefusedError (INVALID_METADATA, 422, objectName, ruleName, changedKeys with type first, the prescription in the message) for a body that changes a packaged rule's condition or type, and null otherwise. Nothing calls it yet. Wiring it into the metadata save door is the follow-up card. Under B, registerObject needs no build-time refusal, and this PR adds none.

Idempotence (#8027). subtractExtenderContributions no longer subtracts validations. 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's message / active off a reworded copy before the fold reads them. getPackagedObjectBase serves 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/objectql patch (ruling A: Metadata-protocol: none grades every entry patch), carrying the BREAKING banner and the ADR-0087 not-required disposition.

Tests.

  • New registry-validation-rule-by-name.test.ts has 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: false deactivates; different names add; and re-folding is idempotent, including a legacy row stored while the fold still appended.
  • engine-validation-tenant-rewrite.test.ts changes 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's 2a499b92 merged with main 630b4078, 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.js from source, so no dist rebuild is involved. Every mutation went red:

# mutation in registry.ts red
A01 mergeValidationsByName concatenates again 8 failed / 18
A02 the tenant's message is not read 8 failed
A03 the tenant's active is not read 1 failed (active: false deactivates)
A04 tenantRuleSettings answers undefined 7 failed
A05 withPackagedOwnerRules is a no-op (owner copy keeps its own condition) 1 failed (a package fix reaches the tenant)
A06 the tenant copy's condition is carried into the fold 2 failed (owner fix; changed condition never runs)
A07 tenantRuleCopyRefusal never refuses 2 failed (condition, type)
A08 packagedRules skips extension rules 5 failed
A09 schema defaults not applied before comparing 1 failed (stamped defaults change nothing)
A10 the load report is not called 1 failed (reported once)
A11 the report's once-per-registry guard removed 1 failed (reported once)
A12 tenant settings take the last copy, not the first 1 failed (legacy appended row)

The 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

  • tenantRuleCopyRefusal has no caller in this PR, on purpose: ruling B puts the refusal at the save door (follow-up card behind the protocol.ts serial queue on [PM seat] domain:server — 🟢 os-tesla · 由 os-tesla 账户的项目负责 #6367).
  • A copy stored before a package upgrade changed the rule cannot be told apart from one the tenant changed. That is why the load path reports and runs the package's rule instead of refusing.
  • The predicate compares every key except message, active and _-prefixed loader stamps, so it is stricter than "condition or type": any package-owned key (for example fields, events, severity) counts as a change. That matches C′'s "everything else is the package's".
  • The commit subjects carry ! from seat 4's commit. The changeset level follows Metadata-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/objectstack on e5e62b28c3 derived 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 lint script's command over packages/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

… 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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 24 documentable anchor(s).

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f91ab5a08af7a7c4e5d3f6f89dd22fef67193ba0.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 55 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 212 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 — 20 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 f91ab5a08af7a7c4e5d3f6f89dd22fef67193ba0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3045c8370f55c5795a9492278a2d2cc91b84903b — the merge of head e5e62b28c322e16c662339fc3d6931a79a26647a into base f91ab5a08af7a7c4e5d3f6f89dd22fef67193ba0, 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 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

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

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head e5e62b28c322e16c662339fc3d6931a79a26647a · 2026-10-31T17:14Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e5e62b28c322e16c662339fc3d6931a79a26647a
Local-runs: none
Scope: packages/objectql/src/registry.ts (mergeValidationsByName, tenantRuleSettingsOf, withTenantRuleSettings, changedPackagedRuleKeys + VALIDATION_RULE_DEFAULTS, ValidationRuleCopyRefusedError / tenantRuleCopyRefusal, withPackagedOwnerRules, packagedRules, reportChangedRuleCopies, subtractExtenderContributions no longer subtracting validations, getPackagedObjectBase merged by name), the new registry-validation-rule-by-name.test.ts (14), one expectation in engine-validation-tenant-rewrite.test.ts, the dated ADR-0029 D9.2a note, the changeset (@objectstack/objectql patch, BREAKING banner, ADR-0087 not-required). 5 files, +705/−54. Fixes #27949 (ruling C′ 6524229156 + ruling B 6545641964; claim 6545757428 on seat 4's 2a499b92; dev report 6546544933). Tier H by path (docs/adr/**).
Inputs: card #27949 body + both rulings + claim + dev report, the PR body, the PR diff at this head (every registry hunk read), git grep on origin/main at the merge-base (the concatenation at registry.ts L568, the validations subtraction at L4960, getPackagedObjectBase's append at L5168, ADR-0029 D9.2a), check-runs (read 2026-10-31T17:20Z: all 49 latest-per-name check-runs completed, 34 success + 15 skipped, none failed, none running) — 49 checks. Nothing built or run here; the seat's numbers (objectql 507 files / 9847, 12 ablations all red, 70 derived gates, dogfood on the card) are the dev report's.

① Derived judgments

  • The fold's new rule is the ruled one. On main mergeObjectDefinitions concatenated validations (L568) and subtractExtenderContributions removed only byte-equal extender rows, so a tenant overlay's reworded copy ran beside the extension's original. At head a rule's identity is its name: mergeValidationsByName drops every base rule of a name the later layer carries and appends the later layer's rules; different names stay additive (D9.2a's protection); between code layers the later same-named rule replaces, as a field does. getPackagedObjectBase serves the packaged set through the same merge, and validations leaves the subtraction list (['indexes'] only), so a folded base re-folds to itself (A tenant object overlay drops every objectExtensions field from the resolved schema, and effective contradicts code in the same response #8027 pin).
  • What the tenant owns — message and active, nothing else (C′). tenantRuleSettings is read only when the fold's base is a tenant overlay or runtime-authored owner (tenantRuleSettingsOf, first copy wins); withPackagedOwnerRules replaces a copy of the owner's rule with the owner's rule plus those two keys, and mergeValidationsByName applies the same settings to an extension's rule. The copy's type, condition and every other key never enter the enforced rule — the A05/A06 ablations pin it.
  • Load path reports, never refuses (B). reportChangedRuleCopies compares each copy with packagedRules (owner + extensions merged by name) through changedPackagedRuleKeys, which fills schema defaults (events, priority, severity) before comparing so a parse-stamped copy is not a change, ignores message/active/_-stamps, and reports once per registry per (object, rule, copy, packaged) at error with the prescription. Nothing throws; a package upgrade cannot fail a boot on stored data. registerObject adds no build-time refusal — as B orders.
  • The save-door predicate exists, uncalled. tenantRuleCopyRefusal(name, body) returns ValidationRuleCopyRefusedError (INVALID_METADATA, 422, objectName, ruleName, changedKeys with type first) or null; wiring it into the metadata save door is metadata-protocol: refuse at the save door a tenant overlay that changes a packaged validation rule's condition or type (ruling C′ / B on #27949, the save-door half) #28448 behind the protocol.ts serial queue. The predicate is stricter than "condition or type" (any package-owned key), which is C′'s "everything else is the package's" — stated in the PR.
  • Declarations. Clause-②: no (narrowing) — a copy's own condition no longer runs; Metadata-protocol: none — nothing an author can write changes at load (ruling B's own line); patch with a BREAKING banner under ruling A's grading. ADR-0087 not-required holds: no key moves and no stored row needs rewriting. The one changed expectation in engine-validation-tenant-rewrite.test.ts (served and refused once, with the tenant's sentence) is the behaviour the card filed for.
  • ADR note records both rulings in D9.2a's own terms (different names add; same name merges by name with the package's condition; refusal at the save door; load only reports; idempotence by the merge). Governed path → Tier H approval owed after this record.

② Rulings cited

  • 6524229156 (director#1, Tesla 「同意」 2026-10-30T13:40Z): C′ — merge by name, message/active to the tenant, condition to the package. 6545641964 (director#2, Tesla 「同意」 2026-10-31T15:47Z): B — the loud refusal sits at the save door; load reports and runs the package's rule. Both implemented as ruled; no open ruling.

③ Narrowing / widening

  • Narrowing at load for exactly one shape: a tenant overlay copy of a packaged or extension rule that changed more than message/active — its own condition no longer runs; the package's does, named once at error. Declared, corpus-safe (the fold reads stored rows as they are). Everything else is unchanged or now runs once instead of twice.

④ Behaviour / flags

Implemented-by: claude/project-thread-9ii7md
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz
VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc

维护者速读

  • 改了什么:同一对象里的校验规则改为按 name 合并。租户对套件规则的同名副本只保留它的提示文字(message)和开关(active),条件仍由套件决定;副本改了条件的,加载时只记一条 error 日志,并运行套件的规则,不会让启动失败。
  • 为什么改:你 10-30 的裁定 C′(按名合并,message/active 归租户,条件归套件)和 15:47 对清单第 2 批的「同意」(裁定 B:保存时拒绝,加载时只报告)。
  • 风险与回滚:只收窄一种情况,就是租户副本改了 message/active 以外的内容,这时它自己的条件不再运行;其余情况不变,或由原来重复运行两次变为只运行一次。回滚即 revert。
  • 先看:packages/objectql/src/registry.ts 的 mergeValidationsByName 和 reportChangedRuleCopies。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/registry-validation-rule-by-name.test.ts,14 项全部通过。
  • 需要你做的:本 PR 改了 ADR-0029 的 D9.2a 注记,属于 Tier H,需要 os-zhuang(或 hotlong)批准后才能合并。席位转为 ready 时会请求审阅。

席位意见:契约复审 PASS,两项裁定都已按原文实现。保存时的拒绝放在 #28448,它被本 PR 阻塞,要排在 protocol.ts 串行队列里。可以批准。

席位核对

ACCEPT · seat domain:server#1 · head e5e62b28 · 2026-10-31T17:21Z

  1. Shape: Fixes #27949, draft, base main, 5 files, +705/−54. The patch changeset for @objectstack/objectql carries a BREAKING banner and says ADR-0087 is not required. No packages/spec path. The commit subject carries ! from seat 4's work; history was not rewritten.
  2. Ruling: C′ 6524229156 and B 6545641964.
  3. Contract review: lane PASS 6546672439 at this head. It declares Clause-②: no (narrowing) and Metadata-protocol: none.
  4. Gates / check-runs:
    • Dev report 6546544933: objectql 507/507 files and 9847/9847 tests. All 12 ablations went red. All 70 derived gates exited 0. The dogfood battery had no failures.
    • On head e5e62b28: 37 success, 19 skipped, 1 running, 0 failed.
  5. Accepted deviations:
  6. Landing: check-governed-merges --pr 28445 reports GOVERNED, Tier H (docs/adr/** ×1). The seat readies the PR, and the relay's governed-review leg requests os-zhuang and hotlong. No auto-merge is armed by the seat. After an authorized approval, the seat lands it, or the maintainer enqueues it.

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 size/l tests tooling

Projects

None yet

2 participants