Repository navigation
Commit c90b5b1
fix(metadata-protocol)!: refuse an object whose datasource names no datasource at save and publish (#26912)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X3b8PhkSKHUoQZv2MEwGvNf)_
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 (#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 #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 #26708's head `0da44906`: `protocol.ts` (the one
shared file) auto-merges; the only conflict is
`packages/runtime/src/domains/packages.ts`, between #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.
<details><summary>Red on main, green on the fix</summary>
- 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)`.
</details>
## 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 #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`
(#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](https://claude.ai/code/session_01FEkQ5nuvdPiLSVSMGPCsuK)_
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e1ab32e commit c90b5b1
8 files changed
Lines changed: 703 additions & 2 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- qa/dogfood/test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
| 87 | + | |
87 | 88 | | |
88 | 89 | | |
89 | 90 | | |
| |||
111 | 112 | | |
112 | 113 | | |
113 | 114 | | |
114 | | - | |
| 115 | + | |
115 | 116 | | |
116 | 117 | | |
117 | 118 | | |
| |||
124 | 125 | | |
125 | 126 | | |
126 | 127 | | |
| 128 | + | |
| 129 | + | |
127 | 130 | | |
128 | 131 | | |
129 | 132 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| 172 | + | |
172 | 173 | | |
173 | 174 | | |
174 | 175 | | |
| |||
312 | 313 | | |
313 | 314 | | |
314 | 315 | | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
315 | 375 | | |
316 | 376 | | |
317 | 377 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
| 10 | + | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| |||
92 | 93 | | |
93 | 94 | | |
94 | 95 | | |
| 96 | + | |
| 97 | + | |
95 | 98 | | |
96 | 99 | | |
97 | 100 | | |
| |||
169 | 172 | | |
170 | 173 | | |
171 | 174 | | |
| 175 | + | |
172 | 176 | | |
173 | 177 | | |
174 | 178 | | |
| |||
195 | 199 | | |
196 | 200 | | |
197 | 201 | | |
| 202 | + | |
198 | 203 | | |
199 | 204 | | |
200 | 205 | | |
| |||
476 | 481 | | |
477 | 482 | | |
478 | 483 | | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
479 | 505 | | |
480 | 506 | | |
481 | 507 | | |
| |||
0 commit comments