Repository navigation
Commit bda53bb
fix(metadata-protocol, spec)!: the save doors and composeStacks refuse an
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgULzXHHREefG78bonpN6vqt)_
Fixes #25211
Clause-②: yes (narrowing)
Metadata-protocol: narrowed
The save-door half of ADR-0015 §4.2, after #25168 / #25192.
`object.external` is allowed only on a datasource whose `schemaMode` is
not `'managed'`. #25192 judged the halves a parse and a single stack can
see. This PR judges the pair where the metadata API holds both sides,
and where a composition does:
- **Object save door** (`saveMetaItem`, `object`): an object with
`external` is judged against the datasource its own `datasource` names,
read the way this door's by-name read serves it (the environment's
stored row, else the code layer's registered definition). A managed one
is refused with `422 INVALID_METADATA`, the finding at `external`,
nothing stored.
- **Datasource save door** (`saveMetaItem`, `datasource`): a datasource
saved as managed, or with no `schemaMode`, is refused with `422
INVALID_METADATA` while objects bind it with `external`. There is one
finding per object, at `schemaMode`, and the row is unchanged. The
objects counted are the environment's active object rows plus the
registry's objects that no stored row overlays.
- **Publish re-judge** (`promoteDraftForPublish`): both halves are
judged again on the draft body, so a draft that was legal when saved is
refused if the other side changed before publish. `publishMetaItem` and
each item of a package publish promote through it.
- **`composeStacks`** (step 3b, the artifact pass): an `external` object
in one input bound to a managed datasource that another input declares
is refused with `STACK_CROSS_REFERENCE_INVALID` (422), in either order
and under `last` and `preserve`. It reuses `defineStack`'s collector, so
the finding is the same text.
- **One rule:** `externalBindingDatasourceRefusal` is exported from
`@objectstack/spec/data` (two api-surface rows), and every door above
calls it. ⛔ There is no second copy of the judgement. The predicate's
rule is unchanged, and `objectql` is untouched.
`force: true` does not skip it, because `force` acknowledges a
destructive diff and this is not one. A datasource that no layer holds
still gets no verdict.
## Measured (base `3e073894db`, before any edit)
A temporary probe drove the real `saveMetaItem` / `publishMetaItem` over
a stateful `sys_metadata` double. A second probe ran `defineStack` +
`composeStacks`. Neither probe is committed.
| Case | Base | After |
|---|---|---|
| `wh` stored with no `schemaMode`, then an object `external` on `wh`
(publish) | object stored | `422 INVALID_METADATA` at `external`, no
object row |
| same, `wh` stored `schemaMode: 'managed'` | stored | 422, no object
row |
| same, draft save, then publish | draft stored, then promoted | the
draft save is refused 422 (publish answers `404 NO_DRAFT`) |
| same, `force: true` | stored | 422 |
| `wh` federated + stored external object, then `wh` re-saved with no
`schemaMode` or managed (publish and draft) | stored | `422
INVALID_METADATA` at `schemaMode`, `wh` row still `external` |
| control: `wh` `schemaMode: 'external'` | both stored | both stored |
| `composeStacks([A, B])`: A declares `wh` managed or with no
`schemaMode`, B has `external` on `wh`; both `defineStack` calls build |
passes under `last` and `preserve`, both orders |
`STACK_CROSS_REFERENCE_INVALID`, 1 issue, all 8 combinations |
| control: A declares `wh` external | passes | passes |
## Tests
-
`packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts`
(new, 22 tests): the object half (stored and registered datasource, both
managed shapes, publish / draft / `force`, and the stored row read
before the registered definition), the datasource half (stored and
registered bound objects, one finding each, a stored overlay that drops
`external`, and an object draft that binds nothing), the publish
re-judge in both directions, and controls (`external` / `validate-only`,
a datasource no layer holds, an object without `external`, a binding on
another datasource). Each refusal asserts code, status, path and the
first sentence of the finding, spelled out.
- `packages/spec/src/stack-external-binding-datasource.test.ts` (+13):
`composeStacks` on the cross-input pair, both orders × `last` /
`preserve` × both managed shapes, a `strict: false` input holding the
pair, and the controls.
- `protocol.external-binding-primary-datasource-refusal.test.ts`
(#25192): its registry double gains the `getItem` the object door now
calls, and the `lake` control is relabelled "no layer holds (no
verdict)". Its assertions are unchanged.
- **Ablation:** `externalBindingSaveRefusal` short-circuited to
`undefined` via `scripts/ablation-replace.mjs` (anchor 1 → 0, blob
`530b2be2` → `fc7f395b`). The new file went to 15 failed / 7 passed
(every refusal pin red, every control green), and the restore was
proven: blob == HEAD `530b2be2`, `git diff HEAD` empty. The subject
resolves through the relative `./protocol.js` import (src), so no
`dist/` hop is involved.
## Acceptance notes
- **The datasource-admin door is not judged here.**
`DatasourceAdminService.updateDatasource` (`PATCH
/api/v1/datasources/NAME`) writes through its own path, not
`saveMetaItem`. Measured at base: it stores `wh` as `managed` while a
stored object binds it `external`, and this PR does not touch that path.
It belongs to the services lane, so it is reported to the seat as a
finding and not fixed here.
- **A batch publish** is judged one promotion at a time, in batch order,
against what is already active. A datasource and an object promoted in
the same batch see each other only in that order.
- **The registry half** of the datasource door reads
`engine.registry.getAllObjects()` when the host's registry has it, which
the real `SchemaRegistry` does. A registry double without it counts
stored rows only.
- **Not covered:** `installPackage`'s named-datasource half (a package
carrying both sides installs through its own path), and inputs nested
inside a `preserve`-composed artifact (they carry no top-level objects
or datasources for step 3b).
- **A degraded metadata read** while resolving the named datasource
surfaces as the read's own `503`, never as a verdict.
- **#25168's changeset** said in its **Unchanged** bullet that a stored
or registered datasource gets no verdict. That changeset is left
untouched, and this PR's changeset states the narrowing explicitly.
- **Docs:** `external-datasources.mdx` §5's write example now names
`datasource: 'warehouse'` (as written it was a binding on `default`,
which #25192 refuses), and §2 states the rule and the two doors.
- **Semantic entry**
`object-external-binding-managed-datasource-refused`: amended (surface,
replacement and reason) to name `composeStacks` and the two save doors.
The ADR-0087 marker is `not-required (already-registered …)`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QVYg9FaRCUgSV13upGJSZQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>external binding on a named managed datasource (#25257)1 parent 72fad92 commit bda53bb
13 files changed
Lines changed: 660 additions & 19 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages
- metadata-protocol/src
- spec
- api-surface
- export-origins
- src
- data
- migrations/entries/semantic
- 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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
92 | 103 | | |
93 | 104 | | |
94 | 105 | | |
| |||
250 | 261 | | |
251 | 262 | | |
252 | 263 | | |
253 | | - | |
| 264 | + | |
254 | 265 | | |
255 | 266 | | |
256 | 267 | | |
| |||
0 commit comments