Repository navigation
fix(metadata-protocol)!: refuse an object whose datasource names no datasource at save and publish - #26912
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 9 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: Read 2026-10-27T20:58Z: card #26724 body and all three comments (triage 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. ① Derived judgmentsThe accept-set change the diff makes (right). One narrowing of Seat question 1 — every door that now refuses, every door that still accepts.
Seat question 2 — what counts as known, and the held-binding exemption. Read in
Seat question 4 — the engine-double ledger. Seat question 5 — the envelope. Seat question 6 — the dogfood battery. The report (PR body and os-dev-report) says: on Other derived judgments.
② Semver levelThe changeset
Owed on this head:
③ Boundary flags
Implemented-by: VERDICT: FAIL — on ② alone: the changeset and PR body declare |
…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
席位核对 · ACCEPT ·
|
| 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
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.mdalready teaches that an object binds a datasource by its declared name; no skill or doc states the old acceptance.维护者速读
datasource写了一个不存在的数据源名时,保存(草稿与直接发布)、发布(单项发布与整包 publish-drafts)以及回滚/还原都会被拒绝,返回 422INVALID_METADATA,错误定位在datasource并点名那个数据源。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
unknownDatasourceSaveRefusal(body inprotocol-save-door-refusals.ts, method onObjectStackProtocolImplementation), refuses anobjectwhosedatasourcenames a datasource that does not exist here:422 INVALID_METADATA, one finding atdatasource, "Object 'NAME' names datasource 'DS', and no datasource of that name exists here: none is declared (a*.datasource.tsor a saveddatasource) 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.externalBindingSaveRefusalat its three call sites.saveMetaItemasks it after the schema gate in draft and live mode, which is everyPUT /api/v1/meta/object/:name.promoteDraftForPublishasks it on the draft body beforerepo.promoteDraftwrites; that is the one promotion bothpublishMetaItem(POST /meta/object/:name/publish) andpublishPackageDrafts(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 throughrefuseRestoredBodyTheSaveDoorRefuses, which keeps that family complete. The 404 itself comes from registration (resolveDatasourceBindingstep 1 routes an explicitdatasourceto the driver of that name only), but registration runs after the write has committed, so the refusal belongs at the door.datasource,''or'default'; a driver the engine has registered under the name (IDataEngine.getDriverByName, which covers adriver.*service and anonEnabledrivers.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 (publishPackageDraftspasses the batch's datasource draft names asbatchDatasources, becauselistDraftsgives no type order and the object can be promoted first); and the binding the object already holds. An engine withoutgetDriverByNamecannot say a name is unknown, so nothing is refused on it.os migrate,skipOnEnable) never runsonEnable, so a driver an app registers there is absent from that boot, andos migrate meta --storedand the organization-ownership ceremony re-save stored objects throughsaveMetaItem. 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 everyonEnableanddriver.*registration has happened. I found no other path that registers a datasource after an object that names it is saved.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 locatedissues[]finding), so there is nothing to share yet.git merge-treeagainst 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 head0da44906:protocol.ts(the one shared file) auto-merges; the only conflict ispackages/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 andmainitself, 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), throughsaveMetaItem,publishMetaItemandpublishPackageDraftsover a statefulsys_metadatadouble. Refusals: draft save into a package, draft save naming none, live save, live save underforce; a draft whose datasource is still only a draft, published on its own; a draft stored before the rule, atpublishMetaItemand atpublishPackageDrafts(outcome: 'refused',published: []). Controls: nodatasource,'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 nogetDriverByName.packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts: one morepinBothDoorscase, an object version bound to a driver that has since gone, refused atrollbackMetaItemand atrevertCommit, 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/_draftsand/dataanswers 404; controls each saved as a draft, published withpublish-draftsand served throughPOST/GET /api/v1/data: nodatasource; a driver a plugin registers as adriver.*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.ts,protocol-save-door-refusals.tsandprotocol-authoring-gate.tschecked out at96e7a433and 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 withgit checkout HEAD --, blob hashes equal toHEAD,git diff HEADempty.96e7a433with@objectstack/metadata-protocolrebuilt (ablation-dist-preflightconfirmed the marker absent fromdist/):Tests 2 failed | 3 passed (5). The draft save answered200 {"success":true,…,"state":"draft","message":"Saved object 'dsprobe_badds' (env-wide, package=app.dsprobe26724, state=draft) [seq=1]"}and the live save200 …"state":"active"…; the three controls were green. Restored and rebuilt, preflight confirmed the marker present.Tests 15 passed (15)and the restore family32 passed (32); real stackTests 5 passed (5).Acceptance notes
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.datasourceis unchanged (that exemption is what keepsos migratere-saves working).os migrate meta --storeddoes 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.transactiondoes not roll back, so the batch-refusal case readspublishedCount: 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
79ec0859unless noted.pnpm --filter @objectstack/metadata-protocol test:Test Files 271 passed | 4 skipped (275),Tests 21685 passed | 26 skipped (21711), exit 0;typecheckexit 0 (run before the fixture-only commit79ec0859; that commit touches one test double and one ledger file).vitest run --project repofor 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.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-testexit 3 on the shallow clone (green aftergit fetch --depth=1of its fixture commit);check:dual-build-cjs-loadsexit 3, PREREQUISITE NOT MET (green after the full build);check:engine-double-contract(the ledger learnt the new file's doubles); andcheck:where-matcher(the history double now refuses an operator it does not match).79ec0859,pnpm --filter @objectstack/dogfood testunder 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 inpackaged-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]readafter.donetrue at line 153, andacceptance[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 on1735274f(the same product tree;79ec0859adds 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 anobjectsave, 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