@@ -6817,11 +6817,15 @@ export class ObjectStackProtocolImplementation implements
68176817 * site as stored and emits NO notice for it, so "no notices" alone means
68186818 * "nothing to persist", never "on protocol": {@link migrateStoredMetadata}
68196819 * reads `todos` to tell the two apart. They change nothing in `item`.
6820+ *
6821+ * [#25279] `knownDatasources` is handed to the chain for the refused-shape
6822+ * listing ({@link storedRowKnownDatasources}); it changes nothing either.
68206823 */
68216824 private convertStoredItemDetailed(
68226825 type: string,
68236826 data: unknown,
68246827 onNotice?: (notice: ConversionNotice) => void,
6828+ knownDatasources?: ReadonlyMap<string, { readonly schemaMode?: unknown }>,
68256829 ): { item: unknown; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } {
68266830 const singular = PLURAL_TO_SINGULAR[type] ?? type;
68276831 if (singular === 'flow') return { item: data, notices: [], todos: [] };
@@ -6835,10 +6839,37 @@ export class ObjectStackProtocolImplementation implements
68356839 onTodo: (t) => {
68366840 todos.push(t);
68376841 },
6842+ ...(knownDatasources ? { knownDatasources } : {}),
68386843 });
68396844 return { item, notices, todos };
68406845 }
68416846
6847+ /**
6848+ * [ADR-0015 §4.2 · #25279] The datasource a stored `object` row's
6849+ * `external` binding names, as {@link externalBindingSaveRefusal} would
6850+ * read it at that row's next save ({@link externalBindingDatasourceDefinition}),
6851+ * for {@link migrateStoredMetadata}'s refused-shape listing.
6852+ *
6853+ * A stored object row reaches the conversion chain alone, so the listing's
6854+ * `external` detector could answer only the primary half (`'default'`)
6855+ * while the save door also refuses a binding on a stored or registered
6856+ * managed datasource. Handed down here, the listing names the row exactly
6857+ * when that door refuses it. `undefined` for any other row, a row with no
6858+ * `external`, and a datasource no layer holds (no verdict, as at the door).
6859+ * A read that fails propagates, as it does at the door.
6860+ */
6861+ private async storedRowKnownDatasources(
6862+ singularType: string,
6863+ body: unknown,
6864+ ): Promise<ReadonlyMap<string, { readonly schemaMode?: unknown }> | undefined> {
6865+ if (singularType !== 'object' || !body || typeof body !== 'object' || Array.isArray(body)) return undefined;
6866+ const { external, datasource } = body as { external?: unknown; datasource?: unknown };
6867+ if (external === undefined || external === null) return undefined;
6868+ if (typeof datasource !== 'string' || datasource === '') return undefined;
6869+ const definition = await this.externalBindingDatasourceDefinition(datasource);
6870+ return definition ? new Map([[datasource, definition]]) : undefined;
6871+ }
6872+
68426873 /**
68436874 * Resolve a flow canonicalizer from the live services registry (#4498).
68446875 *
@@ -22553,11 +22584,8 @@ export class ObjectStackProtocolImplementation implements
2255322584 const datasource = typeof body.datasource === 'string' && body.datasource !== '' ? body.datasource : undefined;
2255422585 const definition = datasource === undefined
2255522586 ? undefined
22556- : (await this.readMetaItem({ type: 'datasource', name: datasource }))?.item;
22557- const refusal = externalBindingDatasourceRefusal(
22558- name, body.external, datasource,
22559- definition && typeof definition === 'object' ? definition as { schemaMode?: unknown } : undefined,
22560- );
22587+ : await this.externalBindingDatasourceDefinition(datasource);
22588+ const refusal = externalBindingDatasourceRefusal(name, body.external, datasource, definition);
2256122589 if (refusal) issues.push({ path: refusal.path, message: refusal.message, code: 'custom' });
2256222590 } else if (singularType === 'datasource') {
2256322591 for (const bound of await this.objectsBoundExternallyTo(name)) {
@@ -22575,6 +22603,22 @@ export class ObjectStackProtocolImplementation implements
2257522603 return err;
2257622604 }
2257722605
22606+ /**
22607+ * [#25211 · #25279] The definition of the datasource an `external` object
22608+ * names, as this environment resolves it by name ({@link readMetaItem}: the
22609+ * stored row, else the definition the code layer registers), or `undefined`
22610+ * when no layer holds one. The ONE read behind the object half of
22611+ * {@link externalBindingSaveRefusal} and the stored-migration pass's listing
22612+ * of the same refusal ({@link storedRowKnownDatasources}), so the two cannot
22613+ * judge a binding against different datasources.
22614+ */
22615+ private async externalBindingDatasourceDefinition(
22616+ datasource: string,
22617+ ): Promise<{ readonly schemaMode?: unknown } | undefined> {
22618+ const definition = (await this.readMetaItem({ type: 'datasource', name: datasource }))?.item;
22619+ return definition && typeof definition === 'object' ? definition as { schemaMode?: unknown } : undefined;
22620+ }
22621+
2257822622 /**
2257922623 * [#25211] Every object this environment holds that binds `datasource`
2258022624 * with `external`, for {@link externalBindingSaveRefusal}'s datasource
@@ -25058,9 +25102,15 @@ export class ObjectStackProtocolImplementation implements
2505825102 // flow carrying a `{…}` token at a value slot — a CRUD filter
2505925103 // operand among them — parsed, rewrote nothing and was counted
2506025104 // `canonical`, while `registerFlow` refused it at boot.
25105+ //
25106+ // [#25279] A stored object's `external` binding is judged against the
25107+ // datasource the save door reads for it, so the listing names the
25108+ // row exactly when that door refuses its next save.
2506125109 const { item, notices, todos } = flowResult
2506225110 ? { item: flowResult.storable, notices: flowResult.notices, todos: flowResult.todos ?? [] }
25063- : this.convertStoredItemDetailed(singular, body);
25111+ : this.convertStoredItemDetailed(
25112+ singular, body, undefined, await this.storedRowKnownDatasources(singular, body),
25113+ );
2506425114 const changed = flowResult ? item !== body : notices.length > 0;
2506525115 // [#17321] A site the chain left as stored — no lossless rewrite
2506625116 // exists for it (ADR-0087 D3: a structured TODO, never silence) —
0 commit comments