Skip to content

fix(metadata-protocol)!: refuse an object whose datasource names no datasource at save and publish - #26912

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-8yb110
Oct 27, 2026
Merged

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26724

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — the refusal's finding names the datasource and the three remedies, and skills/objectstack-data/rules/datasources.md already teaches that an object binds a datasource by its declared name; no skill or doc states the old acceptance.

维护者速读

  • 改了什么:对象的 datasource 写了一个不存在的数据源名时,保存(草稿与直接发布)、发布(单项发布与整包 publish-drafts)以及回滚/还原都会被拒绝,返回 422 INVALID_METADATA,错误定位在 datasource 并点名那个数据源。
  • 为什么改:以前这种对象能保存、能发布(回执 "published"),之后却永远注册不上,所有数据接口都回 404 "not registered",作者和 AI 都不知道原因。
  • 风险与回滚:只收紧一个以前就用不了的写法;运行时注册的驱动、包里声明或环境里保存的数据源、同包同批的数据源草稿、对象本来就绑定的值都照常通过。回滚即还原本 PR,无数据迁移。
  • 先看:packages/metadata-protocol/src/protocol-save-door-refusals.ts 的 unknownDatasourceSaveRefusal("什么算存在"的判定);protocol.ts 中 saveMetaItem 与 promoteDraftForPublish 的两处调用;packages/qa/dogfood/test/object-unknown-datasource-refusal.dogfood.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.unknown-datasource-refusal.test.ts,预期 15 个用例全绿,其中拒绝用例在 main 上为红。
  • 需要你做的:无,席位落地。

What changes

  • The refusal. A new save-door refusal, unknownDatasourceSaveRefusal (body in protocol-save-door-refusals.ts, method on ObjectStackProtocolImplementation), refuses an object whose datasource names a datasource that does not exist here: 422 INVALID_METADATA, one finding at datasource, "Object 'NAME' names datasource 'DS', and no datasource of that name exists here: none is declared (a *.datasource.ts or a saved datasource) and no driver is registered under it. …", with the remedies. The envelope is the one the external-binding refusal (ADR-0015 §4.2: the object save door and the datasource save door still accept an external binding on a managed datasource the stack does not declare (follow-up to #25168) #25211) already answers at the same doors, so no error code is added.
  • The seam, and why it covers save and publish. It sits beside externalBindingSaveRefusal at its three call sites. saveMetaItem asks it after the schema gate in draft and live mode, which is every PUT /api/v1/meta/object/:name. promoteDraftForPublish asks it on the draft body before repo.promoteDraft writes; that is the one promotion both publishMetaItem (POST /meta/object/:name/publish) and publishPackageDrafts (POST /packages/:id/publish-drafts) route through, so a refusal there aborts the batch like the other per-item refusals do. The restore doors (rollbackMetaItem, revertCommit) ask it through refuseRestoredBodyTheSaveDoorRefuses, which keeps that family complete. The 404 itself comes from registration (resolveDatasourceBinding step 1 routes an explicit datasource to the driver of that name only), but registration runs after the write has committed, so the refusal belongs at the door.
  • What "exists" means, read from the code. Not refused: no datasource, '' or 'default'; a driver the engine has registered under the name (IDataEngine.getDriverByName, which covers a driver.* service and an onEnable drivers.register, neither of which declares a datasource item); a datasource item the by-name read resolves (readMetaItem: the environment's stored row, else the code layer's *.datasource.ts), connected or not; on a draft save, any datasource draft of that name; on a package publish, a datasource draft of the same batch (publishPackageDrafts passes the batch's datasource draft names as batchDatasources, because listDrafts gives no type order and the object can be promoted first); and the binding the object already holds. An engine without getDriverByName cannot say a name is unknown, so nothing is refused on it.
  • The late-registration path, and why it does not refuse a valid app. A declaration boot (os migrate, skipOnEnable) never runs onEnable, so a driver an app registers there is absent from that boot, and os migrate meta --stored and the organization-ownership ceremony re-save stored objects through saveMetaItem. They keep the binding the stored object already holds, and a binding the object already holds is not judged, so they keep working. Every HTTP save runs after the kernel has started, when every onEnable and driver.* registration has happened. I found no other path that registers a datasource after an object that names it is saved.
  • Overlap with metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708. It refuses a save naming an uninstalled package (uninstalledNamedBaseRefusal, WRITABLE_PACKAGE_REQUIRED). This check stays local to the datasource seam and shares no helper with it. The only common part is "a name the save carries must resolve", and the two answer different envelopes (a 422 naming the package vs a located issues[] finding), so there is nothing to share yet. git merge-tree against metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708's head 0da44906: protocol.ts (the one shared file) auto-merges; the only conflict is packages/runtime/src/domains/packages.ts, between metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708 and main itself, which this diff does not touch.

Why

An author, or an AI writing metadata, who mistypes a datasource name got "published" for an object that can never serve a row, and no pointer to the cause. The door now says so at the save, naming the datasource.

Tests

  • packages/metadata-protocol/src/protocol.unknown-datasource-refusal.test.ts (new, 15 cases), through saveMetaItem, publishMetaItem and publishPackageDrafts over a stateful sys_metadata double. Refusals: draft save into a package, draft save naming none, live save, live save under force; a draft whose datasource is still only a draft, published on its own; a draft stored before the rule, at publishMetaItem and at publishPackageDrafts (outcome: 'refused', published: []). Controls: no datasource, 'default', a run-time-registered driver, a code-layer datasource, a stored datasource, a same-package datasource draft promoted AFTER the object in one batch (the order is asserted), a re-save keeping the held binding (and moving it to another unknown name is refused), and an engine with no getDriverByName.
  • packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts: one more pinBothDoors case, an object version bound to a driver that has since gone, refused at rollbackMetaItem and at revertCommit, with the family's controls.
  • packages/qa/dogfood/test/object-unknown-datasource-refusal.dogfood.test.ts (new, 5 cases) over the booted showcase and the REST doors: the card's draft save and a live save are refused 422 naming the datasource, nothing is listed in /meta/_drafts and /data answers 404; controls each saved as a draft, published with publish-drafts and served through POST/GET /api/v1/data: no datasource; a driver a plugin registers as a driver.* service under a name no datasource item declares; and a datasource drafted in the same package and published in the same batch.
Red on main, green on the fix
  • Protocol doors, with protocol.ts, protocol-save-door-refusals.ts and protocol-authoring-gate.ts checked out at 96e7a433 and the new tests kept: Tests 10 failed | 37 passed (47). Every refusal case is red (both restore doors, the four saves, both publish doors, the draft-only datasource publish, and the second half of the held-binding control); every other control is green. Restored with git checkout HEAD --, blob hashes equal to HEAD, git diff HEAD empty.
  • Real stack, the same three files at 96e7a433 with @objectstack/metadata-protocol rebuilt (ablation-dist-preflight confirmed the marker absent from dist/): Tests 2 failed | 3 passed (5). The draft save answered 200 {"success":true,…,"state":"draft","message":"Saved object 'dsprobe_badds' (env-wide, package=app.dsprobe26724, state=draft) [seq=1]"} and the live save 200 …"state":"active"…; the three controls were green. Restored and rebuilt, preflight confirmed the marker present.
  • On the fix: protocol doors Tests 15 passed (15) and the restore family 32 passed (32); real stack Tests 5 passed (5).

Acceptance notes

  • The other half of the pair is not judged. Deleting or renaming a datasource that objects still bind (DELETE /meta/datasource/:name) would leave those objects in the same 404 state. The card asks about the object's save, and I did not measure that door, so this is noted, not filed.
  • Residue stays re-saveable. An object already stored with an unknown datasource keeps re-saving while its datasource is unchanged (that exemption is what keeps os migrate re-saves working). os migrate meta --stored does not list such rows.
  • Metadata-protocol: narrowed, minor. Ruled by the narrowing contract review (comment 6453465645): a datasource name the door stored before is now refused, the same declaration as ADR-0015 §4.2: the object save door and the datasource save door still accept an external binding on a managed datasource the stack does not declare (follow-up to #25168) #25211's external-binding refusal.
  • Test-double limit. The protocol double's transaction does not roll back, so the batch-refusal case reads publishedCount: 0, published: [] and the causal draft, not the sibling's row. The real engine's Phase-1 transaction is what rolls the sibling back.

Verification

All on head 79ec0859 unless noted.

  • pnpm --filter @objectstack/metadata-protocol test: Test Files 271 passed | 4 skipped (275), Tests 21685 passed | 26 skipped (21711), exit 0; typecheck exit 0 (run before the fixture-only commit 79ec0859; that commit touches one test double and one ledger file).
  • vitest run --project repo for core, objectql, rest, runtime and types: 6/6, 1/1, 5/5, 3/3 and 1/1 files, each exit 0.
  • eslint packages/metadata-protocol packages/qa/dogfood/test/object-unknown-datasource-refusal.dogfood.test.ts --no-inline-config: exit 0.
  • Derived gates (dispatch-gates.mjs --commands), each run with its exit captured before any pipe: 73 of 73, all exit 0. dispatch-gates --ran: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED". The first pass had four non-zero exits, each resolved: check-plugin-teardown-shape --self-test exit 3 on the shallow clone (green after git fetch --depth=1 of its fixture commit); check:dual-build-cjs-loads exit 3, PREREQUISITE NOT MET (green after the full build); check:engine-double-contract (the ledger learnt the new file's doubles); and check:where-matcher (the history double now refuses an operator it does not match).
  • Dogfood battery: on 79ec0859, pnpm --filter @objectstack/dogfood test under the lock after the @objectstack/dogfood^... build: VERDICT command-exit 1 · Test Files 1 failed | 327 passed | 3 skipped (331) · Tests 2 failed | 2686 passed | 20 skipped (2708). The two failures are both in packaged-action-disabled-dispatch-live.dogfood.test.ts (qa(checklist): api-backend — pin the Tier 1 clauses the 18.2.0 sweep scored partial at the layer their oracle names (11 items) #26572's file): acceptance[0] read after.done true at line 153, and acceptance[5] / [2] read a different action row id at line 244. That file passes alone on the same head twice (Tests 8 passed (8), exit 0 both times), and the full battery on 1735274f (the same product tree; 79ec0859 adds only a test double and a ledger row) was VERDICT command-exit 0 · Test Files 328 passed | 3 skipped (331) · Tests 2688 passed | 20 skipped (2708). This diff refuses nothing that is not an object save, so I read it as a load-dependent flake in that file, not a consequence of this change. It is reported here, not skipped or narrowed.

Generated by Claude Code


Generated by Claude Code

…tasource at save, publish and restore

An object saved with a datasource that does not exist was stored and
published, then never registered, so every data call answered 404. The
save doors now refuse it (422 INVALID_METADATA at `datasource`), counting
a registered driver, a declared or stored datasource, a pending draft and
the binding the object already holds as existing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nuvdPiLSVSMGPCsuK
…tasource over the showcase; changeset

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nuvdPiLSVSMGPCsuK
…es not match; the engine-double ledger records the new file

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nuvdPiLSVSMGPCsuK
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 27, 2026
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 19 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/automation/email-templates.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/external-datasources.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/deployment/install-modes.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/ui/apps.mdx _(via /api/v1/meta/drafts (route, the route ledger binds it to client method meta.listDrafts))
  • content/docs/ui/forms.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))

⛔ 9 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-1.mdx (via publishPackageDrafts (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-5.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-6.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-7.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v18/18-0.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v18/18-1.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))
  • content/docs/releases/v18/18-2.mdx (via INVALID_METADATA (literal, a string literal in ObjectStackProtocolImplementation; a string literal in unknownDatasourceSaveRefusal; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 55 of 207 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 — 11 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 1708115e2f7c6e8cff913d954ed2b5ff7e80e2b2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1708115e2f7c6e8cff913d954ed2b5ff7e80e2b2

⚠️ 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 1708115e2f7c6e8cff913d954ed2b5ff7e80e2b2 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 79ec0859481ddc05e4d95ba1fce7cd307306eb53
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts, packages/metadata-protocol/src/protocol.unknown-datasource-refusal.test.ts, packages/qa/dogfood/test/object-unknown-datasource-refusal.dogfood.test.ts, scripts/engine-double-contract.pinned.json + packages/metadata-protocol/src/protocol-drafts.ts (listDrafts), packages/metadata-protocol/src/protocol-package-install.ts, packages/metadata-protocol/src/index.ts, packages/objectql/src/engine.ts (getDriverByName, resolveDatasourceBinding), packages/objectql/src/plugin.ts (driver.* discovery), packages/spec/src/contracts/data-engine.ts (getDriverByName?), packages/runtime/src/app-plugin.ts (code datasources registered in memory), packages/runtime/src/domains/meta.ts, packages/runtime/src/domains/packages.ts, packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/commands/migrate/meta.ts (--stored via migrateStoredMetadata), packages/qa/dogfood/test/packaged-action-disabled-dispatch-live.dogfood.test.ts, .changeset/26724-object-unknown-datasource-refused.md (the prose face, read for ②), .changeset/25211-external-binding-save-doors.md at bda53bb0ba (precedent), scripts/check-changeset-no-major.mjs, AGENTS.md §Metadata-protocol

Read 2026-10-27T20:58Z: card #26724 body and all three comments (triage 6447013547, claim 6451506245, os-dev-report 6453244398); PR #26912 body, its 8-file list, git diff origin/main...79ec0859 (merge base 96e7a4335c, 703 insertions, 2 deletions); the head's check-runs read twice, at 20:52Z and 20:58Z.

Check-runs on the head at the second read: 32 completed/success (Check Changeset, Governed Surface Queue Guard, PR-body guards, Flag docs affected, Spec property liveness, every Lint job incl. Lint · CI, release & engine gates and Lint · docs & code guards, Type Check · source gates, Type Check · debt ledger, Dogfood Regression Gate, Test UI, Check PR Size, Auto Label, filter, Plan Test Core shards), 11 skipped by the path filter (the UI jobs, the dogfood shards 1-3, Dogfood Verify CLI, Temporal Conformance, Build Docs, the two opt-in harnesses), and 11 still in progress: Build Core, Test Core 1/8 through 8/8, Type Check · workspace, Type Check · consumer gates. No check-run on the head has failed. Note for the seat: the dogfood battery did NOT run in CI on this head (its three shards were skipped by the filter; the parent job's success is the no-op), so the dev's local battery report is the only measure of it.

① Derived judgments

The accept-set change the diff makes (right). One narrowing of @objectstack/metadata-protocol's object save/publish/restore doors: an object whose datasource is a non-empty string other than 'default' that resolves to nothing is refused 422 INVALID_METADATA, one finding at datasource. Served over REST by PUT /api/v1/meta/object/:name (draft and live), POST /api/v1/meta/object/:name/publish, POST /api/v1/packages/:id/publish-drafts, and the rollback/revert doors. Nothing is widened. No public export changes: unknownDatasourceSaveRefusal is a private method on the class, the module function in protocol-save-door-refusals.ts is not re-exported by packages/metadata-protocol/src/index.ts (that index exports only SDUI_MANIFEST_SERVICE and the protocol class surface), AuthoringGateHost/SaveDoorRefusalHost are internal host shapes, and promoteDraftForPublish's new batchDatasources is a parameter of a private method. packages/qa/dogfood is private: true, so it owes no changeset entry. Right.

Seat question 1 — every door that now refuses, every door that still accepts.
Doors that store an object and now refuse, read at the head:

  • saveMetaItem draft mode and publish (live) mode, including force: the new block at protocol.ts sits after the schema gate and externalBindingSaveRefusal, before the authoring gate and repo.put. Every PUT /meta/object/:name and the runtime's own meta domain save (packages/runtime/src/domains/meta.ts:1590), the _unpublished app re-save in packages/runtime/src/domains/packages.ts:1917, the organization-ownership ceremony (organization-ownership.ts:903) and os migrate meta --stored (migrateStoredMetadata re-saves rows through saveMetaItem) all route through it.
  • promoteDraftForPublish, on the draft body before repo.promoteDraft: shared by publishMetaItem and publishPackageDrafts (which passes batchDatasources, the batch's datasource draft names, computed from drafts with canonicalMetaType(d.type) === 'datasource'). A refusal inside the batch throws before the write, so the batch aborts like the other per-item refusals; the dogfood and protocol tests pin outcome: 'refused', published: [].
  • rollbackMetaItem and revertCommit, through refuseRestoredBodyTheSaveDoorRefuses (the host gained the member; the family test adds a pinBothDoors case). Right.
    Doors read that do NOT store an object body and so are not in play: installPackage/updatePackage (protocol-package-install.ts writes sys_packages and the registry only); seeds (seedApplied is row data); flows (automation.ts:1942 saves FLOW_METADATA_TYPE); permission-set clone (not an object). The code layer (*.object.ts loaded at boot by registerApp) is not a save door and is not judged by this diff; an unknown datasource declared in code stays a boot-time matter. No archive/zip install path stores object rows outside saveMetaItem (none found in packages/runtime/src/domains/packages.ts; protocolSvc.installPackage at 1492 takes a manifest only).
    Doors that still accept it, by design and stated in the code: (a) an engine with no getDriverByName member refuses nothing (IDataEngine.getDriverByName? is optional in packages/spec/src/contracts/data-engine.ts:358; the real ObjectQL implements it as this.drivers.get(name)); (b) a write that keeps the binding the object already holds (question 2). Direct sys_metadata writes exist only inside sys-metadata-repository.ts (the bundle parent row), reached through repo.put. Verdict: no path that stores an object body is left lenient beyond those two declared exemptions. Right.

Seat question 2 — what counts as known, and the held-binding exemption. Read in unknownDatasourceSaveRefusal, in order:

  • absent, '' or 'default': pass (matches resolveDatasourceBinding step 1, which treats 'default' as fall-through). Right.
  • engine.getDriverByName(name) truthy: pass. Covers a driver.* service (ObjectQL plugin.ts:1146 registers each into drivers), an onEnable drivers.register, and every declared datasource whose driver connected. Right.
  • readMetaItem({ type: 'datasource', name }) answers an item: pass. That read is environment-wide, active state only, no organization row, stored row first then the code layer's registry item; AppPlugin.start() registers every *.datasource.ts (and the config's datasources collection) in the metadata registry in memory as origin: 'code', so a declared datasource whose driver failed or was evicted still resolves. "Same package" is therefore covered by the registry, and "declared in any package or stored in this environment" too. Right.
  • pending: on a DRAFT save, any datasource draft of that name in the environment (listDrafts({ type: 'datasource' }), which excludes disabled packages' drafts and is not package-scoped); on publishPackageDrafts, only the batch's own datasource drafts; on publishMetaItem, on a LIVE save and on a restore, no draft counts. So a draft may name a draft, but nothing goes active naming one. The order-independence inside a batch is asserted by the test (object promoted before the datasource). Right.
  • "registered by runtime config or a plugin that may start later": every HTTP save runs after ObjectKernel.bootstrap() has run every init/start/onEnable, so a plugin that registers in onEnable is visible by request time; a plugin not installed yet is unknown now, and refused — that is the card's ask. Right.
  • The held-binding exemption: current = readMetaItem({ type: 'object', name }) (the active stored row, else the code layer's definition, by name, environment-wide) and current.datasource === datasource passes. Exactly what it lets through: a draft save, live save, publish or restore of an object that ALREADY has an active definition carrying this same datasource string. A NEW object has no current, so it cannot be saved with a typo through this exemption; a typo under a different name than the active row holds is refused (the test's gapdata_typo leg). Residue it preserves: a pre-rule active row with a dangling binding keeps re-saving unchanged (the dev states this in Acceptance notes). A pre-rule DRAFT with a dangling binding and no active row is not exempt: its re-save and its publish are both refused, which is the strict side. One marginal note, not a flag: the read is keyed by object name, not by (name, package), so a save of the same object name into another package carrying the row's own dangling binding passes too; it names no datasource the active row does not already name, so it opens no new typo. Verdict on the dev's sentence "a held binding is not judged, which keeps os migrate re-saves working": accurate; migrateStoredMetadata and the ownership ceremony re-save stored bodies through saveMetaItem with the binding unchanged, and a declaration boot never runs onEnable, so without the exemption those re-saves would be refused for a driver registered there. Right.

Seat question 4 — the engine-double ledger. scripts/engine-double-contract.pinned.json gains three rows for protocol.unknown-datasource-refusal.test.ts (delete, findOne, update, each pinned: 1); 15 additions, 0 deletions, no existing row changed. The new test's double calls assertEngineDeleteDispatch, assertEngineUpdateDispatch and assertEngineFindOnePredicate, which is what the rows pin. The restore-family stub adds only getDriverByName, not a write verb. An addition the new tests require; no pin loosened. Right. Lint · CI, release & engine gates (where check:engine-double-contract runs) is success on the head.

Seat question 5 — the envelope. err.code = 'INVALID_METADATA', err.status = 422, err.issues = [{ path: 'datasource', code: 'custom', message }], err.message = object/NAME failed spec validation: ${specValidationFindings(writeFace, issues)} — byte-for-byte the construction externalBindingSaveRefusal uses at lines 299-302 of the same file. No new error code. The finding's message names both the object and the datasource ("Object 'NAME' names datasource 'DS', and no datasource of that name exists here: …") and the three remedies; the dogfood test pins "path":"datasource" and the sentence on the wire. Right.

Seat question 6 — the dogfood battery. The report (PR body and os-dev-report) says: on 79ec0859 the full battery exited 1, Test Files 1 failed | 327 passed | 3 skipped (331), Tests 2 failed | 2686 passed | 20 skipped (2708), both failures in packaged-action-disabled-dispatch-live.dogfood.test.ts (acceptance[0] read after.done true at line 153; acceptance[5]/[2] read a different action row id at line 244); that file passed alone on the same head twice (8/8, exit 0); the battery on 1735274f (the same product tree; 79ec0859 adds a test double and a ledger row) exited 0 with 328 files passed. Not run here. Read at the head, that file boots its own showcase and exercises sys_metadata_activation, action dispatch over REST and MCP, and a cold reboot; it contains no meta/object save, no datasource, no publish-drafts and no call into the save doors this diff changes, and this diff adds behaviour only to the object save/publish/restore doors. It touches nothing this diff changes; the dev's reading (load-dependent flake, #26572's file, reported not skipped) is consistent with what the file does. Recorded; not a finding against this head. The CI dogfood shards were path-filtered off this head, so no second measure exists.

Other derived judgments.

  • Placement in saveMetaItem: mode === 'draft' ? 'drafts' : undefined passes the draft set only in draft mode; a live save naming a draft-only datasource is refused. Right (the live object would route to no driver).
  • In promoteDraftForPublish the refusal is asked only if (draftForGate), on the draft body, beside the external-binding refusal that already uses that guard. Right.
  • The restore doors ask with no pending and no writeFace: a restore writes an ACTIVE row. Right.
  • The authoring-gate host Pick is widened to include the new member at the single call site in protocol.ts:7057. Right.
  • Test-double limit the dev states (the protocol double's transaction does not roll back the sibling) is honest and the dogfood control over the real stack covers the batch path. Right.
  • PR title fix(metadata-protocol)!:, BREAKING banner and the adr-0087 marker (not-required (no-migration-prescription)) match the (narrowing) arm; Lint · docs & code guards and Check Changeset are success on the head.

② Semver level

The changeset .changeset/26724-object-unknown-datasource-refused.md grades '@objectstack/metadata-protocol': patch and carries Clause-②: no (narrowing) and Metadata-protocol: none; the PR body carries the same two lines. Clause-②: no (narrowing) is right: no published type, export or payload widens, and a published accept set narrows (the changeset's own line says the doors refuse a body "which they stored before").

Metadata-protocol: none is WRONG for this diff, and so is patch. AGENTS.md §3 (line 1088) defines the line as "the change to what a metadata author can write — … a value refused or a requirement tightened is narrowed; … none all else", and "a kind takes at least minor on a package the diff moves". datasource is a key a metadata author writes on ObjectSchema; a value an author could write and have stored (datasource: 'gapdata_nosuch', the card's reproduction answered 200 and "published") is now refused, and the requirement that the name resolve is tightened at every door. That is the definition's own example. check-changeset-no-major.mjs (lines 906-931) says the same and says why the gate did not catch it: "a refused value, a narrowed enum or a changed meaning is invisible to [the ratchets], so the declaration — not the diff — is still the carrier"; Check Changeset success on this head is therefore not evidence for none. The dev's option A ("the refused names never worked; ObjectSchema.datasource already declares a datasource ID") argues about the engine's behaviour after the save, not about what the author could write at the door, which is what the line measures. The repo's precedents decide it the same way: #25211, the sibling refusal at these same doors with this same envelope (.changeset/25211-external-binding-save-doors.md at bda53bb0ba), shipped '@objectstack/metadata-protocol': minor with Metadata-protocol: narrowed; #25598 (escalateTo refuses a dead target), #25571, #25635, #25650 and #25680 all declare narrowed + minor for a value refused.

Owed on this head: '@objectstack/metadata-protocol': minor, Metadata-protocol: narrowed in the changeset body AND on the PR body, Clause-②: no (narrowing) unchanged (the gate cross-checks narrowed against the (narrowing) arm, which is present, so the corrected pair passes check-changeset-no-major). No other changeset entry is owed: packages/qa/dogfood is private and scripts/ publishes nothing. The dev raised exactly this as open_questions[0] and offered to let the seat edit the two lines; the diff on this head carries the wrong value, so this head cannot pass ②.

Clause-②: line as read on the PR body and changeset: Clause-②: no (narrowing) — right.

③ Boundary flags

Implemented-by: claude/project-thread-8yb110
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC
Rendered by the seat's subagent.

VERDICT: FAIL — on ② alone: the changeset and PR body declare Metadata-protocol: none and grade @objectstack/metadata-protocol patch for a diff that refuses a value an author could write; AGENTS.md's definition and the #25211 precedent at these same doors owe narrowed + minor. ① and ③ hold as written; the corrected head needs no code change.

…ol narrowing, minor

Refusing a datasource name the save door stored before is a value refused,
so the changeset declares Metadata-protocol: narrowed and takes minor, as
AGENTS.md's kind-to-level rule asks.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nuvdPiLSVSMGPCsuK
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T21:38Z

Head 623a8ecc51, report 6453244398 (on #26724), claim 6451506245, contract review 6453465645 (CONTRACT_REVIEW_TIER, narrowing face). The review FAILED 79ec0859 on ② only and held ① and ③. It said the corrected head needs no code change. 623a8ecc is exactly that correction: one file, 2 lines, git diff 79ec0859 623a8ecc on the changeset only (@objectstack/metadata-protocol: patch → minor, Metadata-protocol: none → narrowed), and the PR body says the same.

item reading
Shape Draft, base main, Fixes #26724 under triage 6447013547 (p2). 8 files: protocol-save-door-refusals.ts (the new unknownDatasourceSaveRefusal), its call sites in protocol.ts and protocol-authoring-gate.ts, two protocol test files, a real-stack dogfood test, a ledger row added to engine-double-contract.pinned.json, and a minor changeset on @objectstack/metadata-protocol. Clause-②: no (narrowing) and Metadata-protocol: narrowed appear in both the body and the changeset, the same declaration as the #25211 external-binding refusal at these doors.
Fix An object whose datasource names nothing (no registered driver, no declared or stored datasource, no pending datasource draft in the same batch) is refused 422 INVALID_METADATA with one finding at datasource, the existing external-binding envelope and no new code. It refuses at every door that stores an object: saveMetaItem draft and live, promoteDraftForPublish behind publishMetaItem and publishPackageDrafts, and rollback/revert. Absent, empty and 'default' pass. A binding the stored row already holds is not judged again, so os migrate re-saves still work.
Narrowing A datasource name the door stored before is now refused. Under the review's ② ruling this is narrowed + minor. Nothing is widened, and no public export changes.
Dogfood battery Full, no shard, under the lock, pinned to 623a8ecc51 (this head): command exit 0, 328 files and 2688 tests passed, declared skips only. The same result on 79ec0859. packaged-action-disabled-dispatch-live passed in both runs; it failed once in three full runs on this product tree and touches nothing this diff changes. review-checklist.md :50 is met. CI path-skips the dogfood shards, so this local battery is the measure.
Evidence CI green on head 623a8ecc51 (35 success, 13 skipped, 0 failed; the push-time Check Changeset run read the old body line and is superseded by the edit-time run 122468974755, green). Governed check: NOT governed, 705 changed lines. Red first on main: 10 protocol-door tests and 2 real-stack tests red, where draft and live saves answered 200. Ablation restored to HEAD hashes. 73 of 73 derived gates exit 0, --ran 0 NOT-MEASURED.
Overlap Seat 2's #26708 (PR #26920) also edits protocol.ts. Per the seat ruling, there is no shared helper (the envelopes differ), and whichever lands second merges main. The dev's merge-tree against its head 0da44906 auto-merges.
Noted Deleting a datasource while objects still bind it would leave those objects in the same unbound state. Not measured, so no reach. It is noted in the PR, not filed.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip.

Generated by Claude Code

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants