You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f5e6953
Browse filesBrowse the repository at this point in the historyBrowse files
feat(metadata-protocol)!: the save door refuses a tenant's copy of a packaged validation rule that changes the rule's condition, type or any other key the package owns (#28478)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoSvkSSxNZcoCwT3AFsNcUvy)_Fixes#28448
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Ruling-ref: 6545641964 (letter B: the loud refusal sits at the save
door), under 6524229156 (letter C′: `message` and `active` are the
tenant's, everything else is the package's). The load half landed in
#28445 (`50015d84`).
## 维护者速读
- 改了什么:保存 `object` 元数据时,若租户覆盖层里有一条与包内(或扩展包)校验规则同名的副本,且改了
`message`、`active` 以外的任何键(条件、类型等),保存门现在用 `422 INVALID_METADATA`
拒绝,并给出裁定的处方:"To change the condition, add a rule under a new name, or
change it in the package."。判定直接调用 #28445 留下的
`SchemaRegistry.tenantRuleCopyRefusal`,所以加载报告和保存拒绝是同一个判断。
- 为什么改:#28445 之后,这样的副本在加载时只记日志、照包的规则执行;保存却返回成功,改条件的管理员永远不知道自己的条件不生效。裁定 B
要求在保存门上明确拒绝。
- 风险与回滚:以前能保存的请求现在会被拒(收窄,需合约复审)。只改提示语、`active:
false`、新名字的规则照常保存;草稿保存不受影响(发布时才判定);已存数据照旧加载,不需迁移。回滚即 revert 本 PR。
- 先看:`packages/metadata-protocol/src/runtime-authoring-gate.ts` 的
`findPackagedRuleCopyGaps`,以及 `protocol-authoring-gate.ts` 的
`packagedRuleCopyRefusalOf`。`protocol.ts` 未改动(#28451 占用中)。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
--maxWorkers=2
src/protocol-validation-rule-copy-save-refusal.test.ts`,预期 5 个用例全绿(需先构建
`@objectstack/metadata-protocol`)。
- 需要你做的:无,合约复审由席位安排;本 PR 不涉及 Tier H 路径。
## What changes
**Where the refusal sits.** The save door's own refusal set
(`protocol-save-door-refusals.ts`) is reached only through calls written
in `protocol.ts`, and seat 2's #28451 holds that file. So the refusal
joins the **runtime authoring gate**, which `protocol.ts` already calls
on every door this needs: the active save, publish, batch publish, the
draft-publish verdict and the restore door. No `protocol.ts` line
changes.
**The host fact**
(`packages/metadata-protocol/src/protocol-authoring-gate.ts`).
`judgeRuntimeAuthoringRules` gathers host facts impurely and hands them
to the pure gate. For an `object` it now also asks
`packagedRuleCopyRefusalOf(engine, name, body)`, which calls the
registry's `tenantRuleCopyRefusal(name, body)` when the engine's
registry has one and reduces the answer to `{ ruleName, changedKeys,
message }`. A host whose registry has no such predicate (a test double,
a non-ObjectQL engine) answers `undefined`, and the rule is not run.
This keeps metadata-protocol free of an objectql import, and the
judgement is the registry's own, the same one the load report reads.
**The gate rule**
(`packages/metadata-protocol/src/runtime-authoring-gate.ts`). A new
gate-local rule, `validation-rule-copy-changed`
(`VALIDATION_RULE_COPY_CHANGED`), with `findPackagedRuleCopyGaps`. A
refusing verdict becomes one error issue that is:
- located at the copy's first changed key, for example
`validations[1].condition`, or `validations[1].type` (the predicate
lists `type` first);
- worded with the registry's message, which names the object, the rule
and the changed keys;
- hinted with the prescription, "To change the condition, add a rule
under a new name, or change it in the package."
It folds into the gate's one `422 INVALID_METADATA` envelope with
`issues[]`, and `rulesRun` names the rule whenever the host answered
(clean or not). Nothing is written on a refusal.
**What still saves:** a copy that changes only `message` (a reword) or
only `active` (`active: false` switches the rule off), a rule under a
new name, and a draft save (D1: drafts are not gated; publishing the
draft is).
**Changeset** `.changeset/28448-save-door-validation-rule-copy.md`:
`@objectstack/metadata-protocol` `minor` (ruling A: `Metadata-protocol:
narrowed` moves at least one package to `minor`), with the BREAKING
paragraph and the ADR-0087 `not-required (no-migration-prescription)`
disposition. No key of any schema changes, and stored rows keep loading.
## Tests
- New
`packages/metadata-protocol/src/runtime-authoring-gate.validation-rule-copy.test.ts`
(4 cases, pure gate): a refusing verdict is one located issue with the
hint and the rule in `rulesRun`; a clean verdict refuses nothing; no
verdict judges nothing; a draft is not gated.
- New
`packages/objectql/src/protocol-validation-rule-copy-save-refusal.test.ts`
(5 cases, a real `SchemaRegistry` behind the real
`ObjectStackProtocolImplementation` save door): the tenant's own object,
extended by a code package with a rule through `registerObject(...,
'extend')`, then PUT back with an edited copy of the served body.
- A condition change is refused: 422, the issue at
`validations[i].condition` with the prescription, the stored body
unchanged.
- A type change (`script` to `cross_field`, which still parses) is
refused, `type` named.
- A reword saves, and the tenant's sentence is stored.
- `active: false` saves.
- A rule under a new name saves, even with the changed condition.
- It lives in objectql, like its `meta-object-*-roundtrip` siblings,
because objectql depends on metadata-protocol and never the reverse.
- `scripts/engine-double-contract.pinned.json` records the new test's
engine double (`--write`, 3 rows added, 0 lost).
- Sibling suites: `pnpm --filter @objectstack/metadata-protocol exec
vitest run`: Test Files 287 passed / 4 skipped, Tests 21879 passed / 26
skipped. `@objectstack/metadata-protocol` and `@objectstack/objectql`
typecheck: exit 0.
<details><summary>4 ablations, one per refusal leg (each through
scripts/ablation-replace.mjs, restored, final tree clean)</summary>
Run: `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2
src/protocol-validation-rule-copy-save-refusal.test.ts` (5 cases), under
`os-verify-lock`. The objectql test reads metadata-protocol from its
dist, so B1 and B2 rebuilt it and proved the marker with
`scripts/ablation-dist-preflight.mjs` (present after mutate, absent
after restore and rebuild).
| # | mutation | red |
|:--|:--|:--|
| B1 | `packagedRuleCopyRefusalOf` never asks the registry
(`protocol-authoring-gate.ts`, dist marker `ablation-b1`) | 2 failed / 5
(condition, type) |
| B2 | `findPackagedRuleCopyGaps` drops the finding
(`runtime-authoring-gate.ts`, dist marker `ablation-b2`) | 2 failed / 5
(condition, type) |
| B3 | `type` not package-owned in the registry predicate
(`registry.ts`, source) | 1 failed / 5 (type) |
| B4 | `condition` not package-owned in the registry predicate
(`registry.ts`, source) | 1 failed / 5 (condition) |
The three saving pins stay green under every mutation, so they pin the
allowed side rather than the wiring.
</details>
## Acceptance notes
- `protocol.ts` is untouched (serial constraint, #28451). The dispatch
named the save-door refusal set; reaching it needs a `protocol.ts` call,
so the runtime authoring gate, already called from every door, carries
the rule instead.
- The predicate refuses on any package-owned key, not only condition or
type. That is C′ ("everything else is the package's"); the card's
condition and type are the minimum, and both are pinned.
- A draft save is not judged (D1, as for every gate-local rule);
publishing it is. The `package-author` channel returns before the gate,
as it does for every gate rule, so a package author's own save is not
judged here.
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1`, the gate's migration hatch,
turns this refusal into its loud log like every other gate rule. A copy
saved under it still runs the package's rule at load and is reported
there (#28445).
- An in-process `getMetaItem` returns the registry's own rule objects;
the save-door test clones the served body before editing it (a wire
client always holds a copy).
## Verification
<details><summary>Derived gates and dogfood</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `5b370a2809` derived 69 commands over 6
paths (474 changed lines). Each ran with stdin from `/dev/null`, its
exit captured by redirect before any pipe. All 69 exit 0.
`dispatch-gates --ran`: `69 derived famil(ies) accounted for — 69 run, 0
NOT-MEASURED`, exit 0.
On the previous head, `check:engine-double-contract` and
`check:objectql-double-limit` were red on the new test's engine double
(an unrecorded ledger row; a `find` that ignored the caller's `limit`).
The fix is the last commit: the double applies the bound after the
filter, and the ledger records it.
Scoped lint, using the root `lint` script's command over
`packages/metadata-protocol` and the new objectql test: exit 0.
Full dogfood (owed by the `(narrowing)` arm), on `5b370a2809` after a
dts build of `@objectstack/metadata-protocol`, under `os-verify-lock`:
`NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter
@objectstack/dogfood test`: Test Files 362 passed / 3 skipped (365),
Tests 2922 passed / 28 skipped (2950), exit 0.
</details>
No Tier H path (`check-governed-merges --test` exits 0 on every changed
path). It stays a draft until the contract review is on record; the seat
lands it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(metadata-protocol)!: the save door refuses a tenant's copy of a packaged validation rule that changes the rule's condition, type or any other key the package owns (#28448)
6
+
7
+
Clause-②: no (narrowing) — an `object` publish that carries a same-named copy of a validation rule a code layer ships (the packaged owner's or an extension's), and changes anything but the copy's `message` and `active`, is now refused with `422 INVALID_METADATA`. The same publish was stored before, and the fold ran the package's rule instead of the copy's.
8
+
Metadata-protocol: narrowed
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored row needs rewriting: a copy stored before this change keeps loading, reported once at load, and the package's rule runs for it, as the registry fold already did. Only a new write of such a copy is refused, and its refusal carries the prescription. -->
11
+
12
+
**BREAKING** for a tenant save that changes a packaged validation rule (ADR-0029 D9.2a, 2026-10-30 note; ruling of record 6524229156, letter C′, and ruling of record 6545641964, letter B, which puts this refusal at the save door).
13
+
14
+
-**Before.** The registry fold ran the package's rule with the tenant copy's `message` and `active`, and said once at load (`validation_rule_copy_changed`) that the copy's condition does not run. The save that stored the copy answered success, so the admin who changed the condition was never told.
15
+
-**Now.** The runtime authoring gate asks the registry's own predicate, `SchemaRegistry.tenantRuleCopyRefusal`, the one the load report reads. A publish of a copy that changes a package-owned key is refused with one issue, rule `validation-rule-copy-changed`, located at the copy's first changed key (for example `validations[1].condition`), with the prescription: "To change the condition, add a rule under a new name, or change it in the package." Nothing is written.
16
+
-**Still saved.** A copy that changes only `message` (a reword) or `active` (`active: false` switches the rule off). A rule under a new name, which stays additive. A draft save, which no gate rule refuses; publishing it is judged.
17
+
-**Who sees it.** An admin who edits a packaged or extension rule's condition or type in Studio, or an author doing the same through the metadata API of a runtime: the save now answers with the refusal instead of storing a copy that never runs. To keep a different condition, add it as a rule under a new name.
0 commit comments