From a0e7aa93fc47b80b9a0158d84ccf43541c5c0b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:30:52 +0000 Subject: [PATCH 1/3] wip(metadata-protocol): judge an external binding against a stored or registered datasource at the save doors (#25211) Claude-Session: https://claude.ai/code/session_01QVYg9FaRCUgSV13upGJSZQ Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 134 ++++++++++++++++++ .../src/data/external-binding-datasource.ts | 17 ++- packages/spec/src/data/index.ts | 6 + ...rnal-binding-managed-datasource-refused.ts | 22 ++- packages/spec/src/stack.zod.ts | 33 ++++- 5 files changed, 199 insertions(+), 13 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d9c31bd3a4b..530b2be2083 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -176,6 +176,7 @@ import { type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, type ValidationAdvisoryEvent, discriminateDefaultValueShape, + externalBindingDatasourceRefusal, } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal, METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; // [#13331] The cluster fan-out transport type only — the protocol never @@ -22444,6 +22445,117 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * [ADR-0015 §4.2 · #25211] The save doors' half of "`object.external` only + * on a datasource whose `schemaMode` is not 'managed'": the pair of the item + * being saved and the stored or registered item on the other side of it, + * which no parse can see. + * + * `ObjectSchema`'s refinement judges a binding on the primary datasource + * (`'default'`, written or omitted) inside the schema gate, and + * `defineStack` / `composeStacks` a binding on a datasource a stack + * declares (#25168). A binding on a datasource stored or registered here + * reached neither. Measured before this: an object saved `external` on a + * managed `wh`, and `wh` saved as managed (or with no `schemaMode`, the + * spec default) under objects bound to it with `external`, were both + * stored, in draft and in publish mode and under `force`, and the runtime + * then disagrees with itself about the object: its schema sync skips the + * DDL as federated while the boot validation and the write gate judge it + * as managed. + * + * Both directions, because either item can be the one saved last: + * - an `object` with `external`: judged against the datasource it names, + * as this door's own by-name read resolves it ({@link readMetaItem}: the + * environment's stored row, else the definition the code layer + * registers); + * - a `datasource`: judged against every object that binds it with + * `external` ({@link objectsBoundExternallyTo}). Only the `managed` + * verdict counts here; a binding on the primary datasource is a fact + * about the object alone, which its own save is refused for. + * The judgement is the spec's ({@link externalBindingDatasourceRefusal}), + * ⛔ never a copy, so a finding reads as the parse's and `defineStack`'s + * do. A datasource neither layer holds gets no verdict. + * + * Asked in draft and in publish mode by {@link saveMetaItem}, after the + * schema gate, and again by {@link promoteDraftForPublish} on the draft + * body, because the other half can change between a draft's save and its + * publish. An object's draft is not among the objects a datasource save + * meets: the draft is judged at its own publish. `force` does not reach + * it: `force` acknowledges a destructive diff, and this is no diff. + * Answered as the schema gate answers: `422 INVALID_METADATA`, the + * findings in `issues`, at `external` for an object and at `schemaMode` + * for a datasource. + */ + private async externalBindingSaveRefusal( + singularType: string, + item: unknown, + name: string, + writeFace?: MetadataWriteFace, + ): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + const body = item as { external?: unknown; datasource?: unknown }; + const issues: MetadataIssueEntry[] = []; + if (singularType === 'object') { + if (body.external === undefined || body.external === null) return undefined; + const datasource = typeof body.datasource === 'string' && body.datasource !== '' ? body.datasource : undefined; + const definition = datasource === undefined + ? undefined + : (await this.readMetaItem({ type: 'datasource', name: datasource }))?.item; + const refusal = externalBindingDatasourceRefusal( + name, body.external, datasource, + definition && typeof definition === 'object' ? definition as { schemaMode?: unknown } : undefined, + ); + if (refusal) issues.push({ path: refusal.path, message: refusal.message, code: 'custom' }); + } else if (singularType === 'datasource') { + for (const bound of await this.objectsBoundExternallyTo(name)) { + const refusal = externalBindingDatasourceRefusal(bound.name, bound.external, name, item as { schemaMode?: unknown }); + if (refusal?.shape === 'managed') issues.push({ path: 'schemaMode', message: refusal.message, code: 'custom' }); + } + } + if (issues.length === 0) return undefined; + const err = new Error( + `${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`, + ) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }; + err.code = 'INVALID_METADATA'; + err.status = 422; + err.issues = issues; + return err; + } + + /** + * [#25211] Every object this environment holds that binds `datasource` + * with `external`, for {@link externalBindingSaveRefusal}'s datasource + * half: the environment's stored active rows ({@link readActiveOverlayRows}, + * read as the list read parses them), and the registry's objects no stored + * row overlays (an object's stored row is its base layer, ADR-0029 D9). + * Drafts are not read: a draft does not bind anything until its publish, + * which is judged on its own. + */ + private async objectsBoundExternallyTo(datasource: string): Promise> { + const bound = new Map(); + let records: any[] = []; + try { + records = await this.readActiveOverlayRows({ type: 'object' }); + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const stored = this.storedOverlayEntries({ type: 'object' }, records); + const storedNames = new Set(stored.map((entry) => entry.name)); + const take = (name: string, obj: unknown) => { + const o = obj as { datasource?: unknown; external?: unknown } | null | undefined; + if (o?.datasource === datasource && o.external !== undefined && o.external !== null) bound.set(name, o.external); + }; + for (const entry of stored) take(entry.name, entry.data); + const registry = this.engine.registry; + if (typeof registry?.getAllObjects === 'function') { + for (const obj of registry.getAllObjects() as Array<{ name?: unknown }>) { + if (typeof obj?.name !== 'string' || storedNames.has(obj.name)) continue; + take(obj.name, obj); + } + } + return [...bound].map(([name, external]) => ({ name, external })); + } + /** * [ADR-0138 D2b] The flow half of the anonymous door × elevated flow * refusal: a flow published with `runAs: 'system'` while an @@ -23943,6 +24055,17 @@ export class ObjectStackProtocolImplementation implements if (defaultPageRefusal) throw defaultPageRefusal; } + // [ADR-0015 §4.2 · #25211] An `object` bound `external` to a datasource + // this environment holds as managed, or a `datasource` saved as managed + // while objects bind it with `external`, is refused, in draft and in + // publish mode, after the schema (the body is the type it says, and the + // parse has judged a binding on the primary datasource) and before the + // authoring gate and every write. See {@link externalBindingSaveRefusal}. + { + const bindingRefusal = await this.externalBindingSaveRefusal(singularType, request.item, request.name, request.writeFace); + if (bindingRefusal) throw bindingRefusal; + } + // [ADR-0138 D2b] A flow published with `runAs: 'system'` while an // anonymous endpoint of `type: 'flow'` names it is refused, in publish // mode only (a draft does not execute; its publish is judged at the @@ -25684,6 +25807,17 @@ export class ObjectStackProtocolImplementation implements const defaultPageRefusal = await this.defaultRecordPageSaveRefusal(singularType, draftForGate.body, request.name, 'published'); if (defaultPageRefusal) throw defaultPageRefusal; } + // [ADR-0015 §4.2 · #25211] …and an object draft bound `external` to a + // datasource that is managed by the time it is published, or a + // datasource draft that would go `active` as managed under objects that + // bind it with `external`: the draft save judged the other half as it + // stood then. Judged again here, on the draft body and with the save + // door's predicate and envelope, before `repo.promoteDraft` writes, so a + // batch publish aborts on it too. See {@link externalBindingSaveRefusal}. + if (draftForGate) { + const bindingRefusal = await this.externalBindingSaveRefusal(singularType, draftForGate.body, request.name); + if (bindingRefusal) throw bindingRefusal; + } // [ADR-0138 D2b] …and a flow draft that would go `active` with // `runAs: 'system'` while an anonymous endpoint of `type: 'flow'` names // it: the draft save let it through (a draft does not execute), so its diff --git a/packages/spec/src/data/external-binding-datasource.ts b/packages/spec/src/data/external-binding-datasource.ts index 8a978cd3f78..5da2ce85b6e 100644 --- a/packages/spec/src/data/external-binding-datasource.ts +++ b/packages/spec/src/data/external-binding-datasource.ts @@ -27,10 +27,15 @@ * - the object names another datasource, and that datasource's definition is * visible to the door and declares `schemaMode: 'managed'` (or no * `schemaMode`, which is managed by the spec default). `defineStack`'s - * cross-reference pass judges it for a datasource the same stack declares - * (`stack.zod.ts`). A datasource the door cannot see — one a host or - * another package registers, or one stored at runtime — gets no verdict - * here: absence of a definition is not evidence about its `schemaMode`. + * cross-reference pass judges it for a datasource the same stack declares, + * and `composeStacks`' artifact pass for one another input of the + * composition declares (`stack.zod.ts`). [#25211] At runtime the metadata + * save doors (`@objectstack/metadata-protocol`) judge the pair from both + * sides: an object saved with `external`, against the datasource it names + * (a stored row or a registered definition), and a datasource saved as + * managed, against the objects that bind it with `external`; the draft + * promotion judges both again. A datasource no door can see gets no + * verdict: absence of a definition is not evidence about its `schemaMode`. * * Why the object's OWN `datasource`, and not the datasource a package * `defaultDatasource` or a `datasourceMapping` rule routes it to: ADR-0015's @@ -40,7 +45,9 @@ * binding is a declaration about the object, so it is the object that names * where its remote table lives. * - * Internal to `packages/spec`: not re-exported from `data/index.ts`. + * [#25211] Exported from `@objectstack/spec/data` for the runtime save doors, + * so the rule and its message keep one copy: a door resolves what it can see + * and asks this function, ⛔ never a reimplementation of the judgement. */ /** The host's primary datasource — `HOST_RESERVED_DATASOURCE_NAME` in `stack.zod.ts`, managed by construction. */ diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 0a4b7cb05c6..2e4b99050c1 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -291,6 +291,12 @@ export * from './driver/index'; // metadata read path's per-type redaction hook share a single security list). export * from './datasource-credential-redaction'; +// ADR-0015 §4.2's one predicate — `object.external` only on a datasource whose +// `schemaMode` is not 'managed' (#25168). Exported for the metadata save doors +// (#25211), which resolve the datasource a saved object names and the objects +// a saved datasource is named by, and judge the pair here rather than copy it. +export * from './external-binding-datasource'; + // External Datasource Federation — SQL↔field type compatibility (ADR-0015) export * from './type-compat'; export * from './external-catalog.zod'; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-external-binding-managed-datasource-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-external-binding-managed-datasource-refused.ts index d86d9cacb77..99571e53026 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-external-binding-managed-datasource-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-external-binding-managed-datasource-refused.ts @@ -16,16 +16,23 @@ export const entry: SemanticMigration = { surface: 'object.external together with object.datasource — an external (federated) binding on the platform\'s ' + 'primary datasource (datasource \'default\', also the value when none is written) or on a datasource ' - + 'the same stack declares with schemaMode \'managed\' (or no schemaMode, which is managed by default)', + + 'declared with schemaMode \'managed\' (or no schemaMode, which is managed by default) by the same stack, ' + + 'by another input of the same composition, or as a stored or registered datasource the metadata API sees', replacement: 'Name, in the object\'s own `datasource`, the datasource that holds the remote table, and declare that ' + 'datasource with `schemaMode: \'external\'` (or `\'validate-only\'`) and its `external` settings. An object ' + 'whose rows ObjectStack should own instead drops `external` and stays managed. `ObjectSchema` refuses an ' + '`external` binding whose object `datasource` is `\'default\'` at parse, so `ObjectSchema.create()`, ' + '`defineStack`, `os validate` and the object save door (`422 INVALID_METADATA`) refuse it; `defineStack` ' - + 'refuses one on a managed datasource the same stack declares with `STACK_CROSS_REFERENCE_INVALID`. A ' - + 'package `defaultDatasource` or a `datasourceMapping` rule does not make an object federated: they route ' - + 'its queries, while the boot validation and the write gate read the object\'s own `datasource`.', + + 'refuses one on a managed datasource the same stack declares with `STACK_CROSS_REFERENCE_INVALID`, and ' + + '`composeStacks` one on a managed datasource another input declares. At runtime the metadata save door ' + + '(`PUT /api/v1/meta/object/NAME`) refuses an object bound `external` to a stored or registered managed ' + + 'datasource, and `PUT /api/v1/meta/datasource/NAME` refuses saving a datasource as managed while objects ' + + 'bind it with `external` (both `422 INVALID_METADATA`, in draft and publish mode, and again when a draft ' + + 'is published): change the datasource to `schemaMode: \'external\'` first, or drop `external` from the ' + + 'objects first. A package `defaultDatasource` or a `datasourceMapping` rule does not make an object ' + + 'federated: they route its queries, while the boot validation and the write gate read the object\'s own ' + + '`datasource`.', reason: 'ADR-0015 §1 makes an object federated by the datasource it points to, and §4.2 allows `object.external` ' + 'only when that datasource\'s `schemaMode` is not \'managed\', declared "enforced at metadata-load time". ' @@ -34,9 +41,10 @@ export const entry: SemanticMigration = { + 'object. Its schema sync treats any object carrying `external` as federated and never creates its table, ' + 'while the boot validation skips it as "not federated" and the write gate exempts it because its datasource ' + 'is managed. So the object booted with no table and nothing judged it. The rule is enforced where each half ' - + 'is visible: the object alone shows a binding on `default`, the platform\'s managed primary; a stack that ' - + 'declares the named datasource shows its `schemaMode`. A datasource the door cannot see (one a host or ' - + 'another package registers, or one stored at runtime) gets no verdict at these doors. No mechanical move ' + + 'is visible: the object alone shows a binding on `default`, the platform\'s managed primary; a stack, or a ' + + 'composition, that declares the named datasource shows its `schemaMode`; the metadata save doors see the ' + + 'stored and registered datasources and objects, so they judge the pair whichever of the two is saved last. ' + + 'A datasource no door can see gets no verdict. No mechanical move ' + 'is lossless: dropping `external` turns a remote table into a table ObjectStack creates, and pointing the ' + 'object at another datasource or re-declaring the datasource external are statements about where the rows ' + 'live that only the owner can make. Measured at the change: the one in-tree producer, the showcase\'s ' diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ab3373069cb..5d417f95a0d 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3824,6 +3824,33 @@ function collectExternalBindingDatasourceErrors(config: ObjectStackDefinition): return errors; } +/** + * [ADR-0015 §4.2 · #25211] {@link collectExternalBindingDatasourceErrors} over + * a COMPOSITION: an `external` object of one input bound to a datasource + * another input declares managed (or with no `schemaMode`). + * + * `defineStack` resolves a binding against its own stack's `datasources`, so a + * pair split across two inputs reached the artifact with no verdict: each input + * is legal alone, and the composed artifact registers both, the object as + * federated and its datasource as managed. Judged here over the objects the + * artifact carries (step 2's merge, after the conflict strategy) and the + * `datasources` of every input, by the same collector, so the finding reads + * exactly as the per-stack one does. + * + * What this can newly refuse: a cross-input pair, and, as with the artifact + * pass's other rules, a same-stack pair in an input built with + * `strict: false`, which never reached the per-stack pass. A strict input holds + * no same-stack pair. A datasource name two inputs declare is judged against + * the later declaration (the collector keeps the last one it meets). + */ +function collectComposedExternalBindingDatasourceErrors( + stacks: readonly ObjectStackDefinition[], + composedObjects: ObjectStackDefinition['objects'], +): string[] { + const datasources = stacks.flatMap((stack) => (Array.isArray(stack.datasources) ? stack.datasources : [])); + return collectExternalBindingDatasourceErrors({ objects: composedObjects, datasources } as ObjectStackDefinition); +} + /** * Perform strict cross-reference validation on a parsed stack definition. * Returns an array of error messages (empty if valid). @@ -6597,7 +6624,7 @@ function composeBuiltStacks( // 0. [#20367 ruling B] Every input must be a stack a producer built. The // per-stack refusals run only inside `defineStack`, so an input that never // passed through it is unjudged, and nothing below re-judges it (the - // artifact pass in step 3b re-runs two artifact-scoped rules, not the + // artifact pass in step 3b re-runs the artifact-scoped rules, not the // family). FIRST, and before the single-input early return, so a lone // plain object is refused as well rather than handed straight back. const unbuilt = stacks.flatMap((stack, i) => { @@ -6712,12 +6739,16 @@ function composeBuiltStacks( // inputs, each legal on its own: one deployment holds them both. It needs // no object to resolve against, so it is not behind that pass's // object-count early return. + // [ADR-0015 §4.2 · #25211] …and an `external` object of one input bound + // to a datasource another input declares managed: see + // {@link collectComposedExternalBindingDatasourceErrors}. const artifactCrossRefErrors = [ ...collectArtifactCrossReferenceErrors( stacks, objects as readonly { name: string }[] | undefined, ), ...collectComposedDefaultRecordPageErrors(stacks, opts.manifest === 'preserve'), + ...collectComposedExternalBindingDatasourceErrors(stacks, objects), ]; if (artifactCrossRefErrors.length > 0) { const count = artifactCrossRefErrors.length; From c2899d47b6b709e9d1a4fb8dd49afc1a279e8581 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:26:09 +0000 Subject: [PATCH 2/3] test(metadata-protocol,spec): pin the external-binding save doors and composeStacks, with docs and changeset (#25211) - the object and datasource save doors, draft and publish, force, the publish re-judge, and the controls - composeStacks: the cross-input pair in both orders under last and preserve - #25192's harness gains the registry read the object door now makes - docs: the write example names its datasource; section 2 states the rule - api-surface and export-origins rows for the exported predicate Claude-Session: https://claude.ai/code/session_01QVYg9FaRCUgSV13upGJSZQ Co-authored-by: Claude --- .../25211-external-binding-save-doors.md | 30 ++ .../data-modeling/external-datasources.mdx | 11 +- ...l-binding-named-datasource-refusal.test.ts | 329 ++++++++++++++++++ ...binding-primary-datasource-refusal.test.ts | 13 +- packages/spec/api-surface/data.json | 2 + packages/spec/export-origins/data.json | 2 + .../stack-external-binding-datasource.test.ts | 63 +++- scripts/engine-double-contract.pinned.json | 15 + 8 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 .changeset/25211-external-binding-save-doors.md create mode 100644 packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts diff --git a/.changeset/25211-external-binding-save-doors.md b/.changeset/25211-external-binding-save-doors.md new file mode 100644 index 00000000000..0bfa4f785a9 --- /dev/null +++ b/.changeset/25211-external-binding-save-doors.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +--- + +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 + +Clause-②: yes (narrowing) + +Metadata-protocol: narrowed + + + +**BREAKING** (what the metadata API stores narrows), shipped as `minor` under the 18.x convention for breaking changes. + +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. + +- **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. +- **TO:** + - 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. + - 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. + - 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. + - `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. + - 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' …". +- **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`. +- **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. + +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. + +`@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. diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index 08778e802b5..06af9066dcc 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -89,6 +89,15 @@ export const Customer = ObjectSchema.create({ That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote `customers` table; filters (`?region=EU`) push down to the remote query. +The object's **own** `datasource` must name a datasource declared +`schemaMode: 'external'` or `'validate-only'` (ADR-0015 §4.2). An `external` binding on +`'default'` (also the value when `datasource` is omitted) or on a `managed` datasource is +refused — by `ObjectSchema.create()`, `defineStack` and `composeStacks`, and at runtime by +`PUT /api/v1/meta/object/` with `422 INVALID_METADATA`. The same save door refuses +`PUT /api/v1/meta/datasource/` that would make a datasource `managed` while objects +bind it with `external`: change the datasource to `external` before binding objects, and +remove `external` from the objects before making it managed. + **`field.columnName` no longer exists.** It was removed from `FieldSchema` in the 16.x line (#2377, ADR-0049) — the SQL driver always used the field key as the @@ -250,7 +259,7 @@ object must opt in: ```typescript defineDatasource({ /* ... */ external: { allowWrites: true } }); // datasource gate -ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true } }); // object gate +ObjectSchema.create({ /* ... */ datasource: 'warehouse', external: { remoteName: 'orders', writable: true } }); // object gate ``` With either gate off, insert/update/delete on the federated object is rejected. diff --git a/packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts b/packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts new file mode 100644 index 00000000000..18d10429b3b --- /dev/null +++ b/packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0015 §4.2 · #25211] The metadata save doors refuse an `external` object + * binding on a NAMED datasource this environment holds as managed, from both + * sides of the pair. + * + * ADR-0015 §4.2 allows `object.external` only on a datasource whose + * `schemaMode` is not `'managed'` (an absent `schemaMode` is the spec default, + * `'managed'`). #25168 judged the half the object shows alone (a binding on + * `'default'`, in `ObjectSchema`'s parse) and the half a stack shows + * (`defineStack`). Measured before this file, on the save door: an object saved + * `external` on a stored managed `wh` was stored, in draft and publish mode and + * under `force`; and `wh` re-saved as managed (or with no `schemaMode`) under a + * stored object bound to it with `external` was stored too. + * + * Pinned here, all through `saveMetaItem` / `publishMetaItem`: + * - the object half: `422 INVALID_METADATA`, the finding at `external`, + * nothing stored — against a stored datasource row and a registered one; + * - the datasource half: `422 INVALID_METADATA`, one finding per bound object + * at `schemaMode`, the stored row unchanged — against stored object rows and + * registered objects no stored row overlays; + * - the publish of a draft is judged again, against the other half as it + * stands at publish; + * - controls: a federated datasource, a datasource no layer holds, an object + * without `external`, a binding on another datasource. + * + * Each refusal is asserted by its code, its status and the first sentence of + * its finding, spelled out here, never read back from the predicate under test. + * + * Harness: the real write path over a stateful `sys_metadata` double, the shape + * of `protocol.code-defined-datasource-door.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL refuses. From `@objectstack/metadata-core`, +// not `@objectstack/objectql`, which depends on this package. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + state: string; + metadata: string; + [key: string]: unknown; +} + +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v === null || v === undefined) return row[k] === null || row[k] === undefined; + return whereValueMatches(row[k], v); + }); + +/** + * One kernel. `registryDatasources` are datasource definitions the code layer + * registers (the registry's `getItem` answers them); `registryObjects` are the + * objects the registry holds (`getAllObjects`), as a package ships them. + */ +function makeSession(opts: { + registryDatasources?: Record>; + registryObjects?: Array>; +} = {}) { + const rows = new Map(); + const historyRows: Array> = []; + const engine: any = { + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; + if (table !== 'sys_metadata') return null; + for (const row of rows.values()) if (matchesWhere(row, o.where)) return row; + return null; + }, + async find(table: string, o?: { where?: Record; limit?: number }) { + if (table === 'sys_metadata_history') return historyRows; + if (table !== 'sys_metadata') return []; + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + const matched = Array.from(rows.values()).filter((row) => matchesWhere(row, o?.where ?? {})); + return o?.limit === undefined ? matched : matched.slice(0, o.limit); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + historyRows.push({ ...data }); + return { id: `h_${historyRows.length}` }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + const row = { id: `r_${rows.size + 1}`, ...data } as Row; + rows.set(row.id, row); + return { id: row.id }; + }, + async update(table: string, data: Record, o?: Record) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const id = (o as any)?.where?.id; + const row = rows.get(id); + if (row) rows.set(id, { ...row, ...data }); + return { id: id ?? null }; + }, + async delete(table: string, o?: Record) { + // [#4550] The producer's own delete-verb dispatch contract. + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + return { deleted: rows.delete((o as any)?.where?.id) ? 1 : 0 }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: (type: string, name: string) => + type === 'datasource' ? opts.registryDatasources?.[name] : undefined, + getAllObjects: () => opts.registryObjects ?? [], + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + getArtifactItem: () => undefined, + getAllPackages: () => [], + removeRuntimeShadow: () => false, + removeOverlayEntry: () => {}, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + return { protocol, rows }; +} + +const refusalOf = (run: Promise) => run.then(() => null, (e: any) => e); + +/** The stored rows of one item, as `state|schemaMode-or-external`. */ +const stored = (rows: Map, type: string, name: string) => + [...rows.values()] + .filter((r) => r.type === type && r.name === name) + .map((r) => { + const body = JSON.parse(r.metadata); + return `${r.state}|${type === 'datasource' ? body.schemaMode ?? '-' : body.external ? 'external' : '-'}`; + }); + +/** A schema-valid datasource body. */ +const datasource = (name: string, extra: Record = {}) => + ({ name, label: 'Warehouse', driver: 'sqlite', config: { filename: `${name}.db` }, ...extra }); +const FEDERATED = { schemaMode: 'external', external: { allowWrites: false } }; + +/** A schema-valid object body, bound `external` to `wh` unless `extra` says otherwise. */ +const object = (name: string, extra: Record = {}) => ({ + name, + label: 'Order', + sharingModel: 'private', + fields: { title: { name: 'title', label: 'Title', type: 'text' } }, + datasource: 'wh', + external: { remoteName: 'orders' }, + ...extra, +}); + +/** The finding's first sentence, spelled out. */ +const managedFinding = (objectName: string, ds = 'wh') => + `Object '${objectName}' declares \`external\` on datasource '${ds}', whose \`schemaMode\` is 'managed' (the default when none is declared), so ObjectStack owns its schema and creates its tables.`; + +const expectRefusal = (err: any, envelope: string, path: string, objectNames: string[]) => { + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(err.message).startsWith(`${envelope} failed spec validation: `), String(err?.message)).toBe(true); + expect(err.issues).toHaveLength(objectNames.length); + objectNames.forEach((objectName, i) => { + expect(err.issues[i]).toMatchObject({ path, code: 'custom' }); + expect(String(err.issues[i].message).startsWith(managedFinding(objectName)), err.issues[i].message).toBe(true); + }); +}; + +const MANAGED_SHAPES: Array<[string, Record]> = [ + ['no `schemaMode` (managed by default)', {}], + ["`schemaMode: 'managed'`", { schemaMode: 'managed' }], +]; + +describe('[#25211] the object save door — `external` on a managed datasource this environment holds', () => { + for (const [shape, dsExtra] of MANAGED_SHAPES) { + it.each([ + ['publish', {}], + ['draft', { mode: 'draft' }], + ['publish under `force`', { force: true }], + ])(`a stored datasource with ${shape}: %s is refused 422 at \`external\`, and no object row is stored`, async (_mode, saveOpts) => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', dsExtra) }); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order'), ...saveOpts })); + + expectRefusal(err, 'object/wh_order', 'external', ['wh_order']); + expect(stored(rows, 'object', 'wh_order')).toEqual([]); + }); + } + + it('a datasource the code layer registers as managed refuses the binding the same way', async () => { + const { protocol, rows } = makeSession({ registryDatasources: { wh: datasource('wh', { schemaMode: 'managed' }) } }); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') })); + + expectRefusal(err, 'object/wh_order', 'external', ['wh_order']); + expect(rows.size).toBe(0); + }); + + it("the environment's stored row is the datasource judged, over the registered definition", async () => { + // Registered federated, stored managed: the door reads the stored row + // first, as its own by-name read serves the datasource. + const { protocol, rows } = makeSession({ registryDatasources: { wh: datasource('wh', FEDERATED) } }); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', { schemaMode: 'managed' }) }); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') })); + + expectRefusal(err, 'object/wh_order', 'external', ['wh_order']); + expect(stored(rows, 'object', 'wh_order')).toEqual([]); + }); +}); + +describe('[#25211] the datasource save door — managed while objects bind it with `external`', () => { + for (const [shape, dsExtra] of MANAGED_SHAPES) { + it.each([ + ['publish', {}], + ['draft', { mode: 'draft' }], + ])(`re-saving a federated datasource with ${shape} under a stored external object: %s is refused 422 at \`schemaMode\`, and the row is unchanged`, async (_mode, saveOpts) => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) }); + await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') }); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', dsExtra), ...saveOpts })); + + expectRefusal(err, 'datasource/wh', 'schemaMode', ['wh_order']); + expect(stored(rows, 'datasource', 'wh')).toEqual(['active|external']); + }); + } + + it('one finding per bound object, a stored row and a registered object alike', async () => { + const { protocol, rows } = makeSession({ registryObjects: [object('wh_shipment')] }); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) }); + await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') }); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh') })); + + expectRefusal(err, 'datasource/wh', 'schemaMode', ['wh_order', 'wh_shipment']); + expect(stored(rows, 'datasource', 'wh')).toEqual(['active|external']); + }); + + it('a registered object is judged by the stored row that overlays it, when there is one', async () => { + // The stored row is the object's base layer (ADR-0029 D9): it dropped + // `external`, so the registered binding binds nothing here. + const { protocol, rows } = makeSession({ registryObjects: [object('wh_order')] }); + await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order', { datasource: 'default', external: undefined }) }); + + const result = await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh') }); + + expect(result.success).toBe(true); + expect(stored(rows, 'datasource', 'wh')).toEqual(['active|-']); + }); + + it("an object's draft binds nothing until its publish", async () => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) }); + await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order'), mode: 'draft' }); + + const result = await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh') }); + + expect(result.success).toBe(true); + expect(stored(rows, 'datasource', 'wh')).toEqual(['active|-']); + }); +}); + +describe('[#25211] the publish of a draft is judged again, against the other half as it stands', () => { + it('an object draft saved on a federated datasource is refused at publish once the datasource is managed', async () => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) }); + await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order'), mode: 'draft' }); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', { schemaMode: 'managed' }) }); + + const err = await refusalOf(protocol.publishMetaItem({ type: 'object', name: 'wh_order' })); + + expectRefusal(err, 'object/wh_order', 'external', ['wh_order']); + expect(stored(rows, 'object', 'wh_order')).toEqual(['draft|external']); + }); + + it('a datasource draft saved as managed is refused at publish once an object binds it with `external`', async () => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) }); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', { schemaMode: 'managed' }), mode: 'draft' }); + await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') }); + + const err = await refusalOf(protocol.publishMetaItem({ type: 'datasource', name: 'wh' })); + + expectRefusal(err, 'datasource/wh', 'schemaMode', ['wh_order']); + expect(stored(rows, 'datasource', 'wh').sort()).toEqual(['active|external', 'draft|managed']); + }); +}); + +describe('[#25211] controls — what the doors do not refuse', () => { + it.each([ + ["`schemaMode: 'external'`", FEDERATED], + ["`schemaMode: 'validate-only'`", { schemaMode: 'validate-only', external: { allowWrites: false } }], + ])('a datasource with %s: the datasource and the bound object both save, and the datasource re-saves', async (_label, dsExtra) => { + const { protocol, rows } = makeSession(); + expect((await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', dsExtra) })).success).toBe(true); + expect((await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') })).success).toBe(true); + expect((await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', { ...dsExtra, label: 'Renamed' }) })).success).toBe(true); + expect(stored(rows, 'object', 'wh_order')).toEqual(['active|external']); + }); + + it('an object bound to a datasource no layer holds saves (absence is no evidence about `schemaMode`)', async () => { + const { protocol, rows } = makeSession(); + const result = await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order', { datasource: 'lake' }) }); + expect(result.success).toBe(true); + expect(stored(rows, 'object', 'wh_order')).toEqual(['active|external']); + }); + + it('an object without `external` saves on a managed datasource, and the datasource re-saves managed under it', async () => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh') }); + expect((await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order', { external: undefined }) })).success).toBe(true); + expect((await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', { schemaMode: 'managed' }) })).success).toBe(true); + expect(stored(rows, 'datasource', 'wh')).toEqual(['active|managed']); + }); + + it('a datasource saves managed while external objects bind ANOTHER datasource', async () => { + const { protocol } = makeSession({ registryObjects: [object('lake_order', { datasource: 'lake' })] }); + await protocol.saveMetaItem({ type: 'datasource', name: 'lake', item: datasource('lake', FEDERATED) }); + await protocol.saveMetaItem({ type: 'object', name: 'lake_item', item: object('lake_item', { datasource: 'lake' }) }); + + expect((await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh') })).success).toBe(true); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.external-binding-primary-datasource-refusal.test.ts b/packages/metadata-protocol/src/protocol.external-binding-primary-datasource-refusal.test.ts index 8fd7ee3345e..4e6ca616fe7 100644 --- a/packages/metadata-protocol/src/protocol.external-binding-primary-datasource-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.external-binding-primary-datasource-refusal.test.ts @@ -13,8 +13,11 @@ * INVALID_METADATA`, the finding at `external`, nothing stored. * * The half that needs a named datasource's definition is not this door's - * parse: it cannot see the datasource, so a binding that names one saves here - * whatever that datasource declares. + * parse. [#25211] The save door judges it after the parse, against the + * datasource the object names as this door reads it + * (`protocol.external-binding-named-datasource-refusal.test.ts`); a datasource + * no layer holds gets no verdict, and this harness holds none, so a binding + * that names one saves here. */ import { describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; @@ -41,7 +44,9 @@ function makeProtocol() { assertEngineDeleteDispatch(opts); return { deleted: 0 }; }, - registry: { registerItem: () => {}, registerObject: () => {} }, + // [#25211] `getItem` for the save door's read of a named datasource: + // this harness holds none. + registry: { registerItem: () => {}, registerObject: () => {}, getItem: () => undefined }, }; const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); return { protocol, rows }; @@ -83,7 +88,7 @@ describe('#25168 the object save door refuses an `external` binding on the prima }); it.each([ - ['a binding naming a datasource (this half needs only the object)', { datasource: 'lake', external: { remoteName: 'orders' } }], + ['a binding naming a datasource no layer holds (no verdict)', { datasource: 'lake', external: { remoteName: 'orders' } }], ['no binding, on the primary datasource', { datasource: 'default' }], ])('control — %s saves', async (_label, extra) => { const { protocol, rows } = makeProtocol(); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index f576ee3366c..574527ace17 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -273,6 +273,7 @@ "EqualityOperatorSchema (const)", "ExpressionBody (type)", "ExpressionBodySchema (const)", + "ExternalBindingDatasourceRefusal (interface)", "ExternalCatalog (type)", "ExternalCatalogParsed (type)", "ExternalCatalogSchema (const)", @@ -860,6 +861,7 @@ "effectiveOperationsArray (function)", "emptyGroupValueFor (function)", "expandEmptyOperator (function)", + "externalBindingDatasourceRefusal (function)", "fieldForm (const)", "filterSubtreeProvenanceOf (function)", "foldAsciiCase (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index f14e4a79351..aa38eae6914 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -268,6 +268,7 @@ "EqualityOperatorSchema": "src/data/filter.zod.ts#EqualityOperatorSchema (const)", "ExpressionBody": "src/data/hook-body.zod.ts#ExpressionBody (type)", "ExpressionBodySchema": "src/data/hook-body.zod.ts#ExpressionBodySchema (const)", + "ExternalBindingDatasourceRefusal": "src/data/external-binding-datasource.ts#ExternalBindingDatasourceRefusal (interface)", "ExternalCatalog": "src/data/external-catalog.zod.ts#ExternalCatalog (type)", "ExternalCatalogParsed": "src/data/external-catalog.zod.ts#ExternalCatalogParsed (type)", "ExternalCatalogSchema": "src/data/external-catalog.zod.ts#ExternalCatalogSchema (const)", @@ -846,6 +847,7 @@ "effectiveOperationsArray": "src/data/api-derivation.ts#effectiveOperationsArray (function)", "emptyGroupValueFor": "src/data/aggregation-policy.ts#emptyGroupValueFor (function)", "expandEmptyOperator": "src/data/filter-empty-operator.ts#expandEmptyOperator (function)", + "externalBindingDatasourceRefusal": "src/data/external-binding-datasource.ts#externalBindingDatasourceRefusal (function)", "fieldForm": "src/data/field.form.ts#fieldForm (const)", "filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)", "foldAsciiCase": "src/data/filter.zod.ts#foldAsciiCase (function)", diff --git a/packages/spec/src/stack-external-binding-datasource.test.ts b/packages/spec/src/stack-external-binding-datasource.test.ts index ba1cdf803ff..34f3bd36ac1 100644 --- a/packages/spec/src/stack-external-binding-datasource.test.ts +++ b/packages/spec/src/stack-external-binding-datasource.test.ts @@ -14,9 +14,14 @@ * - the CONTROLS: a datasource declared `external` or `validate-only` is * accepted, and a datasource the stack does not declare gets no verdict; * - `strict: false` skips the judgement by contract. + * + * [#25211] …and `composeStacks` refuses the pair split across two inputs (the + * object in one, its managed datasource in another), which each `defineStack` + * accepted alone, in either order and under `last` and `preserve`, with the + * same finding and envelope. */ import { describe, expect, it } from 'vitest'; -import { defineStack } from './stack.zod'; +import { composeStacks, defineStack } from './stack.zod'; type Envelope = Error & { code?: string; status?: number; issues?: readonly unknown[] }; @@ -112,3 +117,59 @@ describe('defineStack — an `external` binding on the primary datasource', () = ]); }); }); + +describe('[#25211] composeStacks — an `external` binding on a managed datasource another input declares', () => { + const manifest = (id: string) => ({ id, namespace: id.split('.').pop(), version: '1.0.0', type: 'plugin', name: id.split('.').pop() }); + const owner = (mode: Record) => defineStack({ + manifest: manifest('com.acme.dsowner'), + datasources: [warehouse(mode)], + } as never); + const consumer = (extra: Record = {}) => defineStack({ + manifest: manifest('com.acme.wh'), + objects: [order({ datasource: 'warehouse', ...extra })], + } as never); + const composeRefusal = (stacks: unknown[], options?: Record): Envelope | null => { + try { + composeStacks(stacks as never, options as never); + return null; + } catch (e) { + return e as Envelope; + } + }; + + for (const [label, mode] of [["`schemaMode: 'managed'`", { schemaMode: 'managed' }], ['no `schemaMode`', {}]] as const) { + it.each([ + ['owner first, `last`', false, 'last'], + ['consumer first, `last`', true, 'last'], + ['owner first, `preserve`', false, 'preserve'], + ['consumer first, `preserve`', true, 'preserve'], + ])(`${label}: each input builds alone, the composition is refused — %s`, (_order, reversed, manifestOpt) => { + const stacks = reversed ? [consumer(), owner(mode)] : [owner(mode), consumer()]; + const err = composeRefusal(stacks, { manifest: manifestOpt }); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err?.issues).toHaveLength(1); + expect(String(err!.issues![0])).toMatch(/^Object 'wh_order' declares `external` on datasource 'warehouse', whose `schemaMode` is 'managed'/); + expect(err!.message).toContain('composeStacks artifact cross-reference validation failed (1 issue):'); + }); + } + + it("an input built `strict: false` holding the pair itself is refused at composition, as the artifact pass's other rules are", () => { + const loose = defineStack({ + manifest: manifest('com.acme.wh'), + datasources: [warehouse()], + objects: [order({ datasource: 'warehouse' })], + } as never, { strict: false }); + const other = defineStack({ manifest: manifest('com.acme.other') } as never); + expect(composeRefusal([loose, other])?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + }); + + it.each(['last', 'preserve'])("control — the datasource declared `schemaMode: 'external'` composes (`%s`)", (manifestOpt) => { + expect(composeRefusal([owner({ schemaMode: 'external', external: {} }), consumer()], { manifest: manifestOpt })).toBeNull(); + expect(composeRefusal([consumer(), owner({ schemaMode: 'external', external: {} })], { manifest: manifestOpt })).toBeNull(); + }); + + it('control — a datasource no input declares gets no verdict, and an object without `external` on the managed one composes', () => { + expect(composeRefusal([owner({}), consumer({ datasource: 'lake' })])).toBeNull(); + expect(composeRefusal([owner({}), consumer({ external: undefined })])).toBeNull(); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 67ceff7739b..7824c49c978 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -756,6 +756,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.external-binding-primary-datasource-refusal.test.ts", "verb": "delete", From 18e6e72566f3f68afebb2b3d9b868c3aeb15400a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:18:29 +0000 Subject: [PATCH 3/3] docs(data-modeling): ObjectSchema.create() refuses only the 'default' half of an external binding (#25211) The section 2 paragraph credited ObjectSchema.create() with refusing a binding on a managed named datasource, which it cannot see. It refuses the 'default' half; defineStack and composeStacks refuse both halves for a datasource the stack or another input declares; the runtime save door answers 422. Claude-Session: https://claude.ai/code/session_01QVYg9FaRCUgSV13upGJSZQ Co-authored-by: Claude --- content/docs/data-modeling/external-datasources.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index 06af9066dcc..0200c5b9e1c 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -91,9 +91,11 @@ That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote The object's **own** `datasource` must name a datasource declared `schemaMode: 'external'` or `'validate-only'` (ADR-0015 §4.2). An `external` binding on -`'default'` (also the value when `datasource` is omitted) or on a `managed` datasource is -refused — by `ObjectSchema.create()`, `defineStack` and `composeStacks`, and at runtime by -`PUT /api/v1/meta/object/` with `422 INVALID_METADATA`. The same save door refuses +`'default'` (also the value when `datasource` is omitted) is refused by `ObjectSchema.create()`, +which sees only the object. A binding on either `'default'` or a `managed` datasource is refused +by `defineStack` and `composeStacks` (for a datasource the stack, or another input of the +composition, declares), and at runtime by `PUT /api/v1/meta/object/` with +`422 INVALID_METADATA`. The same save door refuses `PUT /api/v1/meta/datasource/` that would make a datasource `managed` while objects bind it with `external`: change the datasource to `external` before binding objects, and remove `external` from the objects before making it managed.