@@ -176,6 +176,7 @@ import {
176176 type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed,
177177 type ValidationAdvisoryEvent,
178178 discriminateDefaultValueShape,
179+ externalBindingDatasourceRefusal,
179180} from '@objectstack/spec/data';
180181import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal, METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared';
181182// [#13331] The cluster fan-out transport type only — the protocol never
@@ -22444,6 +22445,117 @@ export class ObjectStackProtocolImplementation implements
2244422445 return err;
2244522446 }
2244622447
22448+ /**
22449+ * [ADR-0015 §4.2 · #25211] The save doors' half of "`object.external` only
22450+ * on a datasource whose `schemaMode` is not 'managed'": the pair of the item
22451+ * being saved and the stored or registered item on the other side of it,
22452+ * which no parse can see.
22453+ *
22454+ * `ObjectSchema`'s refinement judges a binding on the primary datasource
22455+ * (`'default'`, written or omitted) inside the schema gate, and
22456+ * `defineStack` / `composeStacks` a binding on a datasource a stack
22457+ * declares (#25168). A binding on a datasource stored or registered here
22458+ * reached neither. Measured before this: an object saved `external` on a
22459+ * managed `wh`, and `wh` saved as managed (or with no `schemaMode`, the
22460+ * spec default) under objects bound to it with `external`, were both
22461+ * stored, in draft and in publish mode and under `force`, and the runtime
22462+ * then disagrees with itself about the object: its schema sync skips the
22463+ * DDL as federated while the boot validation and the write gate judge it
22464+ * as managed.
22465+ *
22466+ * Both directions, because either item can be the one saved last:
22467+ * - an `object` with `external`: judged against the datasource it names,
22468+ * as this door's own by-name read resolves it ({@link readMetaItem}: the
22469+ * environment's stored row, else the definition the code layer
22470+ * registers);
22471+ * - a `datasource`: judged against every object that binds it with
22472+ * `external` ({@link objectsBoundExternallyTo}). Only the `managed`
22473+ * verdict counts here; a binding on the primary datasource is a fact
22474+ * about the object alone, which its own save is refused for.
22475+ * The judgement is the spec's ({@link externalBindingDatasourceRefusal}),
22476+ * ⛔ never a copy, so a finding reads as the parse's and `defineStack`'s
22477+ * do. A datasource neither layer holds gets no verdict.
22478+ *
22479+ * Asked in draft and in publish mode by {@link saveMetaItem}, after the
22480+ * schema gate, and again by {@link promoteDraftForPublish} on the draft
22481+ * body, because the other half can change between a draft's save and its
22482+ * publish. An object's draft is not among the objects a datasource save
22483+ * meets: the draft is judged at its own publish. `force` does not reach
22484+ * it: `force` acknowledges a destructive diff, and this is no diff.
22485+ * Answered as the schema gate answers: `422 INVALID_METADATA`, the
22486+ * findings in `issues`, at `external` for an object and at `schemaMode`
22487+ * for a datasource.
22488+ */
22489+ private async externalBindingSaveRefusal(
22490+ singularType: string,
22491+ item: unknown,
22492+ name: string,
22493+ writeFace?: MetadataWriteFace,
22494+ ): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
22495+ if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
22496+ const body = item as { external?: unknown; datasource?: unknown };
22497+ const issues: MetadataIssueEntry[] = [];
22498+ if (singularType === 'object') {
22499+ if (body.external === undefined || body.external === null) return undefined;
22500+ const datasource = typeof body.datasource === 'string' && body.datasource !== '' ? body.datasource : undefined;
22501+ const definition = datasource === undefined
22502+ ? undefined
22503+ : (await this.readMetaItem({ type: 'datasource', name: datasource }))?.item;
22504+ const refusal = externalBindingDatasourceRefusal(
22505+ name, body.external, datasource,
22506+ definition && typeof definition === 'object' ? definition as { schemaMode?: unknown } : undefined,
22507+ );
22508+ if (refusal) issues.push({ path: refusal.path, message: refusal.message, code: 'custom' });
22509+ } else if (singularType === 'datasource') {
22510+ for (const bound of await this.objectsBoundExternallyTo(name)) {
22511+ const refusal = externalBindingDatasourceRefusal(bound.name, bound.external, name, item as { schemaMode?: unknown });
22512+ if (refusal?.shape === 'managed') issues.push({ path: 'schemaMode', message: refusal.message, code: 'custom' });
22513+ }
22514+ }
22515+ if (issues.length === 0) return undefined;
22516+ const err = new Error(
22517+ `${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`,
22518+ ) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] };
22519+ err.code = 'INVALID_METADATA';
22520+ err.status = 422;
22521+ err.issues = issues;
22522+ return err;
22523+ }
22524+
22525+ /**
22526+ * [#25211] Every object this environment holds that binds `datasource`
22527+ * with `external`, for {@link externalBindingSaveRefusal}'s datasource
22528+ * half: the environment's stored active rows ({@link readActiveOverlayRows},
22529+ * read as the list read parses them), and the registry's objects no stored
22530+ * row overlays (an object's stored row is its base layer, ADR-0029 D9).
22531+ * Drafts are not read: a draft does not bind anything until its publish,
22532+ * which is judged on its own.
22533+ */
22534+ private async objectsBoundExternallyTo(datasource: string): Promise<Array<{ name: string; external: unknown }>> {
22535+ const bound = new Map<string, unknown>();
22536+ let records: any[] = [];
22537+ try {
22538+ records = await this.readActiveOverlayRows({ type: 'object' });
22539+ } catch (error) {
22540+ this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
22541+ }
22542+ const stored = this.storedOverlayEntries({ type: 'object' }, records);
22543+ const storedNames = new Set(stored.map((entry) => entry.name));
22544+ const take = (name: string, obj: unknown) => {
22545+ const o = obj as { datasource?: unknown; external?: unknown } | null | undefined;
22546+ if (o?.datasource === datasource && o.external !== undefined && o.external !== null) bound.set(name, o.external);
22547+ };
22548+ for (const entry of stored) take(entry.name, entry.data);
22549+ const registry = this.engine.registry;
22550+ if (typeof registry?.getAllObjects === 'function') {
22551+ for (const obj of registry.getAllObjects() as Array<{ name?: unknown }>) {
22552+ if (typeof obj?.name !== 'string' || storedNames.has(obj.name)) continue;
22553+ take(obj.name, obj);
22554+ }
22555+ }
22556+ return [...bound].map(([name, external]) => ({ name, external }));
22557+ }
22558+
2244722559 /**
2244822560 * [ADR-0138 D2b] The flow half of the anonymous door × elevated flow
2244922561 * refusal: a flow published with `runAs: 'system'` while an
@@ -23943,6 +24055,17 @@ export class ObjectStackProtocolImplementation implements
2394324055 if (defaultPageRefusal) throw defaultPageRefusal;
2394424056 }
2394524057
24058+ // [ADR-0015 §4.2 · #25211] An `object` bound `external` to a datasource
24059+ // this environment holds as managed, or a `datasource` saved as managed
24060+ // while objects bind it with `external`, is refused, in draft and in
24061+ // publish mode, after the schema (the body is the type it says, and the
24062+ // parse has judged a binding on the primary datasource) and before the
24063+ // authoring gate and every write. See {@link externalBindingSaveRefusal}.
24064+ {
24065+ const bindingRefusal = await this.externalBindingSaveRefusal(singularType, request.item, request.name, request.writeFace);
24066+ if (bindingRefusal) throw bindingRefusal;
24067+ }
24068+
2394624069 // [ADR-0138 D2b] A flow published with `runAs: 'system'` while an
2394724070 // anonymous endpoint of `type: 'flow'` names it is refused, in publish
2394824071 // mode only (a draft does not execute; its publish is judged at the
@@ -25684,6 +25807,17 @@ export class ObjectStackProtocolImplementation implements
2568425807 const defaultPageRefusal = await this.defaultRecordPageSaveRefusal(singularType, draftForGate.body, request.name, 'published');
2568525808 if (defaultPageRefusal) throw defaultPageRefusal;
2568625809 }
25810+ // [ADR-0015 §4.2 · #25211] …and an object draft bound `external` to a
25811+ // datasource that is managed by the time it is published, or a
25812+ // datasource draft that would go `active` as managed under objects that
25813+ // bind it with `external`: the draft save judged the other half as it
25814+ // stood then. Judged again here, on the draft body and with the save
25815+ // door's predicate and envelope, before `repo.promoteDraft` writes, so a
25816+ // batch publish aborts on it too. See {@link externalBindingSaveRefusal}.
25817+ if (draftForGate) {
25818+ const bindingRefusal = await this.externalBindingSaveRefusal(singularType, draftForGate.body, request.name);
25819+ if (bindingRefusal) throw bindingRefusal;
25820+ }
2568725821 // [ADR-0138 D2b] …and a flow draft that would go `active` with
2568825822 // `runAs: 'system'` while an anonymous endpoint of `type: 'flow'` names
2568925823 // it: the draft save let it through (a draft does not execute), so its
0 commit comments