Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-9ii7md
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-9ii7md

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

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).

维护者速读

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.
4 ablations, one per refusal leg (each through scripts/ablation-replace.mjs, restored, final tree clean)

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.

Acceptance notes

Verification

Derived gates and dogfood

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.

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

…packaged validation rule that changes what the package owns

The runtime authoring gate asks the registry's own predicate,
SchemaRegistry.tenantRuleCopyRefusal, the one the load report reads, and
folds its verdict into the gate's one 422 as the gate-local rule
validation-rule-copy-changed, located at the copy's first changed key,
with the ruling's prescription. A reworded or switched-off copy and a
rule under a new name still save.

Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV
Co-authored-by: Claude <noreply@anthropic.com>
…d bound, and the engine-double ledger records it

Claude-Session: https://claude.ai/code/session_01L9qibx3A7AeHSnSr39oxaV
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 — 10 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 119931d82832e7f923a4c0a4a8251f9e7ad54a1b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 119931d82832e7f923a4c0a4a8251f9e7ad54a1b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 5b370a280976465a0b2087338d42c618854bb698 · 2026-10-31T22:44Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5b370a280976465a0b2087338d42c618854bb698
Local-runs: none
Scope: PR #28478 (Fixes #28448, the save-door half of ruling B 6545641964 under C′ 6524229156; claim 6550141770; dev report 6549819007), 6 files, +473/−1 against merge-base 52306d40, merge-tree clean (one overlap with main, scripts/engine-double-contract.pinned.json, disjoint rows). Tier S: no docs/adr/**, no skills/**, no packages/spec, no protocol.ts (held by #28451).
Inputs: card #28448 (Wanted 1–4, Where, Contract line), the PR body (速读, ablation table, verification), the full diff, origin/main for SchemaRegistry.tenantRuleCopyRefusal (registry.ts L4544: Error | null, ValidationRuleCopyRefusedError L1858 carrying ruleName and changedKeys with type first; changedPackagedRuleKeys L706 compares every key but message, active and _-prefixed), judgeRuntimeAuthoringRules (protocol-authoring-gate.ts L211: package-author returns clean at L294, a draft is not gated), its three callers (assertRuntimeAuthoringRules from the active save L21542, the publish L23302 and the restore door L144; draftPublishVerdict L490), check-runs (read 2026-10-31T22:48Z: all 48 latest-per-name check-runs completed, 33 success + 15 skipped, none failed, none running) — 48 checks

① Ruling → code

  • Letter B under C′: the loud refusal of a same-named tenant copy that changes the rule's condition or type sits at the save door with the prescription; the load path only reports. The diff is exactly that: judgeRuntimeAuthoringRules gathers packagedRuleCopyRefusalOf(engine, name, body) for an object (the registry's own tenantRuleCopyRefusal, reduced to { ruleName, changedKeys, message }) and hands it to the pure gate; findPackagedRuleCopyGaps turns a refusal into one error issue (validation-rule-copy-changed) at validations[i].<firstChangedKey> with C′'s prescription word for word as the hint; it folds into the gate's one 422 INVALID_METADATA envelope and rulesRun names the rule whenever the host answered. Wanted 2 met: one predicate judges load report and save refusal.
  • Wanted 3 holds by the predicate's own key set (message, active tenant-owned; everything else the package's, so type and any other key refuse, which is C′'s "nor the rule type" and more, as the body says); a rule under a new name is not in the packaged map, so it adds (D9.2a).
  • Door coverage: the gate is called from the active save, publish, batch publish, the draft-publish verdict and restore; package-author and drafts are returned clean before any rule at L294 / D1, so the body's "package author's own save is not judged" and "draft save not gated" are main's existing behaviour, not new arms. The card's protocol-save-door-refusals.ts placement is replaced by the gate for the stated serial reason; the refusal still fires on every door the card means.

② Declarations

③ Pins and ablations

  • Pure gate (4): a refusing verdict is one located issue with the hint and the rule disclosed; null refuses nothing; absent judges nothing; a draft is not gated. Real registry + real save door (5): condition change 422 at validations[i].condition with the prescription and the stored body unchanged; type change refused with type named; reword stored; active: false stored; new name stored with the changed condition. The served body is cloned before editing (in-process reads hand back the registry's own rule objects), a trap the file names.
  • Four ablations (host never asks; gate drops the finding; type / condition not package-owned in the registry predicate), each red on the matching pin only, the three saving pins green under every mutation; dist markers proven for the two metadata-protocol legs. 69 derived families exit 0; full dogfood green; the earlier engine-double ledger red fixed on this head (find honours limit, 3 ledger rows).

④ Traps and should-fix

Implemented-by: claude/project-thread-9ii7md
Reviewed-by: triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc

维护者速读

  • 改了什么:租户对象中与套件规则同名的校验规则副本,如果改了套件拥有的内容(条件、类型等),保存时直接拒绝。返回 422 INVALID_METADATA,定位到第一个被改的键,并附上处理建议:「要改条件,请新建一条不同名的规则,或在套件中修改」。只改提示文字(message)、开关(active)或新建不同名规则,照常保存。
  • 为什么改:你 15:47 对批次 [WIP] Fix issue in job step execution #433 的「同意」(裁定 B:拒绝放在保存时)。加载时的报告已经在 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 合并。
  • 风险与回滚:这是收窄,只影响改了套件规则内容的保存;已保存的副本仍可加载,只在加载时报告。回滚即 revert。
  • 先看:packages/metadata-protocol/src/runtime-authoring-gate.ts 和 protocol-authoring-gate.ts。判断用的是加载报告用的同一个谓词 tenantRuleCopyRefusal。
  • 两分钟验证:先构建 metadata-protocol,再运行 pnpm --filter @objectstack/objectql exec vitest run src/protocol-validation-rule-copy-save-refusal.test.ts。
  • 需要你做的:无,席位落地。

席位意见:契约复审 PASS,protocol.ts 未改动,不必等 #28451。可以直接落地。

席位核对

ACCEPT · seat domain:server#1 · head 5b370a28 · 2026-10-31T22:49Z

  1. Shape: Fixes #28448. Draft, base main, 6 files, +473/−1. It carries a minor changeset for @objectstack/metadata-protocol (a BREAKING note and the ADR-0087 not-required marker). No packages/spec path, and protocol.ts is untouched.
  2. Ruling: B 6545641964 under C′ 6524229156.
    • The save door refuses a same-named copy that changes a package-owned key, and gives C′'s prescription word for word.
    • The judge is the same predicate as the load report.
    • A reword, active: false, a new name and a draft all still save.
  3. Contract review: lane PASS 6550179790 at this head. It declares Clause-②: no (narrowing) and Metadata-protocol: narrowed.
  4. Gates / check-runs:
    • Dev report 6549819007: 69/69 delta gates exit 0, --ran reconciles, dogfood passed 362 files / 2922 tests, and four ablations went red.
    • On head 5b370a28: 48 check-runs completed, 33 success, 15 skipped, 0 failed.
  5. Accepted deviations:
    • The refusal sits in the runtime authoring gate rather than protocol-save-door-refusals.ts, because that file is reachable only through protocol.ts. The gate already covers every save door the card means.
    • The engine-double ledger gained 3 rows, because the gate requires them.
  6. Landing: check-governed-merges --pr 28478 reports NOT governed. The seat readies the PR and arms auto-merge under the maintainer's 「按 pm-dispatch 审过的pr你可以自主落地」.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 22:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit f5e6953 Oct 31, 2026
57 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-9ii7md branch October 31, 2026 23:10
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/m tests tooling

Projects

None yet

1 participant