Skip to content

Commit a0e7aa9

Browse files
committed
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 <noreply@anthropic.com>
1 parent 3e07389 commit a0e7aa9

5 files changed

Lines changed: 199 additions & 13 deletions

File tree

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ import {
176176
type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed,
177177
type ValidationAdvisoryEvent,
178178
discriminateDefaultValueShape,
179+
externalBindingDatasourceRefusal,
179180
} from '@objectstack/spec/data';
180181
import { 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

‎packages/spec/src/data/external-binding-datasource.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@
2727
* - the object names another datasource, and that datasource's definition is
2828
* visible to the door and declares `schemaMode: 'managed'` (or no
2929
* `schemaMode`, which is managed by the spec default). `defineStack`'s
30-
* cross-reference pass judges it for a datasource the same stack declares
31-
* (`stack.zod.ts`). A datasource the door cannot see — one a host or
32-
* another package registers, or one stored at runtime — gets no verdict
33-
* here: absence of a definition is not evidence about its `schemaMode`.
30+
* cross-reference pass judges it for a datasource the same stack declares,
31+
* and `composeStacks`' artifact pass for one another input of the
32+
* composition declares (`stack.zod.ts`). [#25211] At runtime the metadata
33+
* save doors (`@objectstack/metadata-protocol`) judge the pair from both
34+
* sides: an object saved with `external`, against the datasource it names
35+
* (a stored row or a registered definition), and a datasource saved as
36+
* managed, against the objects that bind it with `external`; the draft
37+
* promotion judges both again. A datasource no door can see gets no
38+
* verdict: absence of a definition is not evidence about its `schemaMode`.
3439
*
3540
* Why the object's OWN `datasource`, and not the datasource a package
3641
* `defaultDatasource` or a `datasourceMapping` rule routes it to: ADR-0015's
@@ -40,7 +45,9 @@
4045
* binding is a declaration about the object, so it is the object that names
4146
* where its remote table lives.
4247
*
43-
* Internal to `packages/spec`: not re-exported from `data/index.ts`.
48+
* [#25211] Exported from `@objectstack/spec/data` for the runtime save doors,
49+
* so the rule and its message keep one copy: a door resolves what it can see
50+
* and asks this function, ⛔ never a reimplementation of the judgement.
4451
*/
4552

4653
/** The host's primary datasource — `HOST_RESERVED_DATASOURCE_NAME` in `stack.zod.ts`, managed by construction. */

‎packages/spec/src/data/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ export * from './driver/index';
291291
// metadata read path's per-type redaction hook share a single security list).
292292
export * from './datasource-credential-redaction';
293293

294+
// ADR-0015 §4.2's one predicate — `object.external` only on a datasource whose
295+
// `schemaMode` is not 'managed' (#25168). Exported for the metadata save doors
296+
// (#25211), which resolve the datasource a saved object names and the objects
297+
// a saved datasource is named by, and judge the pair here rather than copy it.
298+
export * from './external-binding-datasource';
299+
294300
// External Datasource Federation — SQL↔field type compatibility (ADR-0015)
295301
export * from './type-compat';
296302
export * from './external-catalog.zod';

‎packages/spec/src/migrations/entries/semantic/18.object-external-binding-managed-datasource-refused.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,23 @@ export const entry: SemanticMigration = {
1616
surface:
1717
'object.external together with object.datasource — an external (federated) binding on the platform\'s '
1818
+ 'primary datasource (datasource \'default\', also the value when none is written) or on a datasource '
19-
+ 'the same stack declares with schemaMode \'managed\' (or no schemaMode, which is managed by default)',
19+
+ 'declared with schemaMode \'managed\' (or no schemaMode, which is managed by default) by the same stack, '
20+
+ 'by another input of the same composition, or as a stored or registered datasource the metadata API sees',
2021
replacement:
2122
'Name, in the object\'s own `datasource`, the datasource that holds the remote table, and declare that '
2223
+ 'datasource with `schemaMode: \'external\'` (or `\'validate-only\'`) and its `external` settings. An object '
2324
+ 'whose rows ObjectStack should own instead drops `external` and stays managed. `ObjectSchema` refuses an '
2425
+ '`external` binding whose object `datasource` is `\'default\'` at parse, so `ObjectSchema.create()`, '
2526
+ '`defineStack`, `os validate` and the object save door (`422 INVALID_METADATA`) refuse it; `defineStack` '
26-
+ 'refuses one on a managed datasource the same stack declares with `STACK_CROSS_REFERENCE_INVALID`. A '
27-
+ 'package `defaultDatasource` or a `datasourceMapping` rule does not make an object federated: they route '
28-
+ 'its queries, while the boot validation and the write gate read the object\'s own `datasource`.',
27+
+ 'refuses one on a managed datasource the same stack declares with `STACK_CROSS_REFERENCE_INVALID`, and '
28+
+ '`composeStacks` one on a managed datasource another input declares. At runtime the metadata save door '
29+
+ '(`PUT /api/v1/meta/object/NAME`) refuses an object bound `external` to a stored or registered managed '
30+
+ 'datasource, and `PUT /api/v1/meta/datasource/NAME` refuses saving a datasource as managed while objects '
31+
+ 'bind it with `external` (both `422 INVALID_METADATA`, in draft and publish mode, and again when a draft '
32+
+ 'is published): change the datasource to `schemaMode: \'external\'` first, or drop `external` from the '
33+
+ 'objects first. A package `defaultDatasource` or a `datasourceMapping` rule does not make an object '
34+
+ 'federated: they route its queries, while the boot validation and the write gate read the object\'s own '
35+
+ '`datasource`.',
2936
reason:
3037
'ADR-0015 §1 makes an object federated by the datasource it points to, and §4.2 allows `object.external` '
3138
+ 'only when that datasource\'s `schemaMode` is not \'managed\', declared "enforced at metadata-load time". '
@@ -34,9 +41,10 @@ export const entry: SemanticMigration = {
3441
+ 'object. Its schema sync treats any object carrying `external` as federated and never creates its table, '
3542
+ 'while the boot validation skips it as "not federated" and the write gate exempts it because its datasource '
3643
+ 'is managed. So the object booted with no table and nothing judged it. The rule is enforced where each half '
37-
+ 'is visible: the object alone shows a binding on `default`, the platform\'s managed primary; a stack that '
38-
+ 'declares the named datasource shows its `schemaMode`. A datasource the door cannot see (one a host or '
39-
+ 'another package registers, or one stored at runtime) gets no verdict at these doors. No mechanical move '
44+
+ 'is visible: the object alone shows a binding on `default`, the platform\'s managed primary; a stack, or a '
45+
+ 'composition, that declares the named datasource shows its `schemaMode`; the metadata save doors see the '
46+
+ 'stored and registered datasources and objects, so they judge the pair whichever of the two is saved last. '
47+
+ 'A datasource no door can see gets no verdict. No mechanical move '
4048
+ 'is lossless: dropping `external` turns a remote table into a table ObjectStack creates, and pointing the '
4149
+ 'object at another datasource or re-declaring the datasource external are statements about where the rows '
4250
+ 'live that only the owner can make. Measured at the change: the one in-tree producer, the showcase\'s '

‎packages/spec/src/stack.zod.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3824,6 +3824,33 @@ function collectExternalBindingDatasourceErrors(config: ObjectStackDefinition):
38243824
return errors;
38253825
}
38263826

3827+
/**
3828+
* [ADR-0015 §4.2 · #25211] {@link collectExternalBindingDatasourceErrors} over
3829+
* a COMPOSITION: an `external` object of one input bound to a datasource
3830+
* another input declares managed (or with no `schemaMode`).
3831+
*
3832+
* `defineStack` resolves a binding against its own stack's `datasources`, so a
3833+
* pair split across two inputs reached the artifact with no verdict: each input
3834+
* is legal alone, and the composed artifact registers both, the object as
3835+
* federated and its datasource as managed. Judged here over the objects the
3836+
* artifact carries (step 2's merge, after the conflict strategy) and the
3837+
* `datasources` of every input, by the same collector, so the finding reads
3838+
* exactly as the per-stack one does.
3839+
*
3840+
* What this can newly refuse: a cross-input pair, and, as with the artifact
3841+
* pass's other rules, a same-stack pair in an input built with
3842+
* `strict: false`, which never reached the per-stack pass. A strict input holds
3843+
* no same-stack pair. A datasource name two inputs declare is judged against
3844+
* the later declaration (the collector keeps the last one it meets).
3845+
*/
3846+
function collectComposedExternalBindingDatasourceErrors(
3847+
stacks: readonly ObjectStackDefinition[],
3848+
composedObjects: ObjectStackDefinition['objects'],
3849+
): string[] {
3850+
const datasources = stacks.flatMap((stack) => (Array.isArray(stack.datasources) ? stack.datasources : []));
3851+
return collectExternalBindingDatasourceErrors({ objects: composedObjects, datasources } as ObjectStackDefinition);
3852+
}
3853+
38273854
/**
38283855
* Perform strict cross-reference validation on a parsed stack definition.
38293856
* Returns an array of error messages (empty if valid).
@@ -6597,7 +6624,7 @@ function composeBuiltStacks(
65976624
// 0. [#20367 ruling B] Every input must be a stack a producer built. The
65986625
// per-stack refusals run only inside `defineStack`, so an input that never
65996626
// passed through it is unjudged, and nothing below re-judges it (the
6600-
// artifact pass in step 3b re-runs two artifact-scoped rules, not the
6627+
// artifact pass in step 3b re-runs the artifact-scoped rules, not the
66016628
// family). FIRST, and before the single-input early return, so a lone
66026629
// plain object is refused as well rather than handed straight back.
66036630
const unbuilt = stacks.flatMap((stack, i) => {
@@ -6712,12 +6739,16 @@ function composeBuiltStacks(
67126739
// inputs, each legal on its own: one deployment holds them both. It needs
67136740
// no object to resolve against, so it is not behind that pass's
67146741
// object-count early return.
6742+
// [ADR-0015 §4.2 · #25211] …and an `external` object of one input bound
6743+
// to a datasource another input declares managed: see
6744+
// {@link collectComposedExternalBindingDatasourceErrors}.
67156745
const artifactCrossRefErrors = [
67166746
...collectArtifactCrossReferenceErrors(
67176747
stacks,
67186748
objects as readonly { name: string }[] | undefined,
67196749
),
67206750
...collectComposedDefaultRecordPageErrors(stacks, opts.manifest === 'preserve'),
6751+
...collectComposedExternalBindingDatasourceErrors(stacks, objects),
67216752
];
67226753
if (artifactCrossRefErrors.length > 0) {
67236754
const count = artifactCrossRefErrors.length;

0 commit comments

Comments
 (0)