Skip to content

Commit bda53bb

Browse files
fix(metadata-protocol, spec)!: the save doors and composeStacks refuse an external binding on a named managed datasource (#25257)
<!-- 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>
1 parent 72fad92 commit bda53bb

13 files changed

Lines changed: 660 additions & 19 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
fix(metadata-protocol)!: the metadata save doors and `composeStacks` refuse an object's `external` binding on a named managed datasource, from either side of the pair
7+
8+
Clause-②: yes (narrowing)
9+
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused) registered by #25168 before this change, amended in this diff: its surface, replacement and reason now name composeStacks and the two metadata save doors -->
13+
14+
**BREAKING** (what the metadata API stores narrows), shipped as `minor` under the 18.x convention for breaking changes.
15+
16+
ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'` (an absent `schemaMode` is `'managed'` by the spec default). #25168 enforced the halves a parse and a single stack can see. A binding on a datasource stored or registered at runtime, or declared by another input of a composition, still got no verdict: the save door stored an `external` object on a managed datasource, stored a datasource re-saved as managed under objects bound to it with `external`, and `composeStacks` accepted the pair split across two inputs. The runtime then disagrees with itself about the object: its schema sync skips the table as federated while the boot validation and the write gate judge it as managed.
17+
18+
- **FROM:** `PUT /api/v1/meta/object/NAME` stored an object with `external` and a `datasource` naming a stored or registered datasource declared `schemaMode: 'managed'` or with no `schemaMode`, in draft and publish mode and under `force`. `PUT /api/v1/meta/datasource/NAME` stored a datasource as managed (or with no `schemaMode`) while stored or registered objects bound it with `external`. `composeStacks` accepted an `external` object of one input bound to a managed datasource another input declares.
19+
- **TO:**
20+
- The object save door refuses the object with `422 INVALID_METADATA`, the finding at `external`, nothing stored. The datasource it judges against is the one the door's own by-name read serves: the environment's stored row, else the definition the code layer registers.
21+
- The datasource save door refuses the datasource with `422 INVALID_METADATA`, one finding per bound object at `schemaMode`, the stored row unchanged. The objects it judges are the environment's active object rows and the registered objects no stored row overlays. An object's draft binds nothing until it is published.
22+
- Both are judged in draft and publish mode, and again when a draft is published (`publishMetaItem` and each item of a package publish), against the other half as it stands then. `force` does not skip them.
23+
- `composeStacks` refuses the cross-input pair with `STACK_CROSS_REFERENCE_INVALID` (422), in either input order and under every `manifest` strategy, `preserve` included. An input built with `strict: false` that holds the pair itself is refused here too, as with the artifact pass's other rules.
24+
- Every finding is the spec predicate's, so it reads as the parse's and `defineStack`'s do: "Object 'OBJECT' declares `external` on datasource 'NAME', whose `schemaMode` is 'managed' …".
25+
- **Unchanged:** the rule itself. A binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted on every door. A datasource no layer holds gets no verdict: the absence of a definition is no evidence about its `schemaMode`. The datasource-admin service's update (`PATCH /api/v1/datasources/NAME`) does not pass through the metadata save door and is not judged by this change. Rows already stored in the refused shape still load as before; a save of either side that leaves the pair inconsistent is refused, so the next object save waits until the datasource is external or the object drops `external`.
26+
- **The fix for an author:** keep the pair consistent, in this order. To federate, save the datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings first, then bind objects to it. To make a datasource managed, remove `external` from the objects that bind it first, then save the datasource.
27+
28+
This narrows the **Unchanged** bullet of the #25168 changeset, which said a binding on a datasource "registered by the host or another package, or stored at runtime" gets no verdict at the load doors: the metadata save doors now judge it, and `composeStacks` judges one another input declares.
29+
30+
`@objectstack/spec/data`: exports `externalBindingDatasourceRefusal` and its result type `ExternalBindingDatasourceRefusal`, the one predicate behind the rule, so a runtime door resolves the datasource it can see and asks it rather than copying the judgement. Its behaviour is unchanged.

‎content/docs/data-modeling/external-datasources.mdx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ export const Customer = ObjectSchema.create({
8989
That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote
9090
`customers` table; filters (`?region=EU`) push down to the remote query.
9191

92+
The object's **own** `datasource` must name a datasource declared
93+
`schemaMode: 'external'` or `'validate-only'` (ADR-0015 §4.2). An `external` binding on
94+
`'default'` (also the value when `datasource` is omitted) is refused by `ObjectSchema.create()`,
95+
which sees only the object. A binding on either `'default'` or a `managed` datasource is refused
96+
by `defineStack` and `composeStacks` (for a datasource the stack, or another input of the
97+
composition, declares), and at runtime by `PUT /api/v1/meta/object/<name>` with
98+
`422 INVALID_METADATA`. The same save door refuses
99+
`PUT /api/v1/meta/datasource/<name>` that would make a datasource `managed` while objects
100+
bind it with `external`: change the datasource to `external` before binding objects, and
101+
remove `external` from the objects before making it managed.
102+
92103
<Callout type="warn">
93104
**`field.columnName` no longer exists.** It was removed from `FieldSchema` in the
94105
16.x line (#2377, ADR-0049) — the SQL driver always used the field key as the
@@ -250,7 +261,7 @@ object must opt in:
250261

251262
```typescript
252263
defineDatasource({ /* ... */ external: { allowWrites: true } }); // datasource gate
253-
ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true } }); // object gate
264+
ObjectSchema.create({ /* ... */ datasource: 'warehouse', external: { remoteName: 'orders', writable: true } }); // object gate
254265
```
255266

256267
With either gate off, insert/update/delete on the federated object is rejected.

0 commit comments

Comments
 (0)