Skip to content
18 changes: 18 additions & 0 deletions .changeset/27803-composed-datasource-connector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
---

fix(spec)!: `composeStacks` refuses a datasource whose `external.connector` names no connector instance the composed inputs declare (#27803)

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type of the package's public surface changes. `composeStacks` now applies, over every input, the cross-reference rule strict `defineStack` already applies to one stack (ADR-0015 amendment D7): a datasource's `external.connector` must name a declared connector instance with a provider and an auth credential. The refused artifacts are authoring defects that a `strict: false` input carried past the per-stack pass, fixed by declaring the instance or correcting the name in the package source. There is nothing for `objectstack migrate meta` to rewrite. -->

**BREAKING** accept-set narrowing in `composeStacks`, and so in every CLI command that loads a project whose config composes two or more packages (`os build`, `os validate`, `os lint`, `os dev`, `os serve`).

- **Before.** `defineStack` judged a datasource's `external.connector` against its own stack's `connectors` only, and an input built with `strict: false` skipped that judgement. Composing such an input carried a dangling name, a catalog descriptor (no `provider`) or an instance with no `auth` into the artifact with no verdict. The datasource then had no credential to borrow, found only when it first connected.
- **Now.** The composition's artifact pass judges every input's datasources against the connectors of every input, with the same three findings as the per-stack check, under the `STACK_CROSS_REFERENCE_INVALID` envelope (422). A datasource in one input that borrows an instance another input declares composes. An input that is itself a multi-package artifact is read through its package bodies, where its connectors and datasources live. A connector name declared twice in the flattened artifact is judged against the later entry, which is the one the registry keeps; when the earlier declaration could have lent, the finding names both and asks for the duplicate to be renamed or removed. A connector name that two packages of the artifact declare is kept twice, and the first one registered lends, so when one of the two can lend and the other cannot, the borrow is refused as ambiguous in either order.
- **Unchanged.** A strict `defineStack` refuses a dangling name in its own stack as before, so an artifact built from strict inputs that each resolve their own references is refused only when another input declares the borrowed connector name with an entry that cannot lend.

**The remedy.** Declare the connector instance the datasource borrows (a `connectors` entry with a `provider` and an `auth` credential) in one of the composed packages, or correct the name. If the upstream needs no credential, drop `external.connector` from the datasource.
4 changes: 3 additions & 1 deletion packages/spec/src/contracts/data-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,9 @@ export interface IDataEngine {
* `schemaMode`, with or without a reference — `credentialsRef` is valid on
* a managed datasource too, #8153) and answer FRESH copies: a reader must
* not be able to reach through this accessor and mutate the write gate's
* own input.
* own input. Each entry carries the definition's stored `driver` id, and
* omits the key for a definition that never named one (see
* {@link EngineDatasourceDef.driver}).
*/
listDatasourceDefs?(): EngineDatasourceDef[];

Expand Down
8 changes: 5 additions & 3 deletions packages/spec/src/data/injected-system-column-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,9 @@ export function injectedSystemColumnDefs(def: unknown): Record<string, Readonly<
* consumers on two sides of the package boundary need the SAME identity
* verdict: the #4326 round-trip strip in `@objectstack/metadata-core`, and
* {@link resolveInjectedColumnProvenance} below. It is also the convergence
* target for the hand-rolled `equalsShippedDef` copies the #7865 ruling lists
* (plugin-security / plugin-sharing), when next touched.
* target for the hand-rolled `equalsShippedDef` copies the #7865 ruling lists.
* plugin-sharing's is gone (it reads {@link isOwnerAnchorUnprovable} now), so
* plugin-security's is the one left, to converge when next touched.
*/
export function isInjectedColumnDefinition(
value: unknown,
Expand Down Expand Up @@ -439,7 +440,8 @@ export function externalColumnMapTargets(def: unknown, field: string): boolean {
*
* Fail direction: `true` narrows (no owner predicate, no row by ownership),
* `false` keeps ordinary owner scoping, which on a column the remote lacks
* also matches no row. Both are closed; neither widens to every row.
* matches no row or raises (Postgres and MySQL raise on the unknown column,
* SQLite matches no row); neither widens to every row.
*/
export function isOwnerAnchorUnprovable(def: unknown): boolean {
if (externalColumnMapTargets(def, SystemFieldName.OWNER_ID)) return false;
Expand Down
197 changes: 196 additions & 1 deletion packages/spec/src/stack-datasource-connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,34 @@
* is accepted, and so is one naming a disabled instance;
* - CONTROL: a datasource with no `connector` is untouched by the rule.
*
* …and `composeStacks` judges the same reference over every input's
* `connectors` (the composed counterpart, #25211 shape):
*
* - a dangling name, a descriptor or an instance with nothing to lend, carried
* by an input built with `strict: false`, is refused with the same finding
* and envelope, under `last` and `preserve`;
* - a connector name declared twice in one registration slot (the flattened
* artifact's top level) is judged against the later entry, the one the
* registry keeps, and that finding names both declarations and prescribes
* the duplicate's fix;
* - a connector name two PACKAGES declare (two slots: the registry keeps
* both, and the first registered lends) is refused as ambiguous in either
* order when one entry can lend and the other cannot, and accepted when
* both can;
* - a nested multi-package input (a `manifest: 'preserve'` artifact) is read
* through its package bodies, where its connectors and datasources live,
* and an entry it carries in both halves is read once;
* - CONTROL: a datasource borrowing an instance another input declares
* composes, in either order;
* - CONTROL: the per-stack strict refusal is unchanged, so a strict input
* never carries a dangling name into a composition.
*
* The pairings the key can never serve (both channels, a built-in driver, a
* managed datasource) are the datasource schema's half, pinned in
* `data/datasource.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import { defineStack } from './stack.zod';
import { composeStacks, defineStack } from './stack.zod';

const manifest = { id: 'com.example.erp', name: 'erp', version: '1.0.0', type: 'app' as const };

Expand Down Expand Up @@ -117,3 +139,176 @@ describe('a datasource borrows the credential of a connector instance its stack
expect(refusalOf({ manifest, datasources: [plain] })).toBeUndefined();
});
});

describe('composeStacks judges external.connector against the connectors of every input', () => {
const pluginManifest = (id: string) => ({
id,
namespace: id.split('.').pop(),
version: '1.0.0',
type: 'plugin',
name: id.split('.').pop(),
});
const owner = (connector: Record<string, unknown> = instance()) =>
defineStack({ manifest: pluginManifest('com.acme.sap'), connectors: [connector] } as never);
const looseBorrower = (connector = 'sap_s4') =>
defineStack({ manifest: pluginManifest('com.acme.orders'), datasources: [sapOrders(connector)] } as never, {
strict: false,
});
const composeRefusal = (stacks: unknown[], options?: Record<string, unknown>): Envelope | undefined => {
try {
composeStacks(stacks as never, options as never);
return undefined;
} catch (err) {
return err as Envelope;
}
};
const expectComposedFinding = (refused: Envelope | undefined, fragment: string): void => {
expectOneFinding(refused, fragment);
expect(refused?.issues).toHaveLength(1);
expect(refused?.message).toContain('composeStacks artifact cross-reference validation failed (1 issue):');
};

it.each([
['owner first, `last`', false, 'last'],
['borrower first, `last`', true, 'last'],
['owner first, `preserve`', false, 'preserve'],
['borrower first, `preserve`', true, 'preserve'],
])('CONTROL: a datasource borrowing an instance another input declares composes — %s', (_order, reversed, manifestOpt) => {
const stacks = reversed ? [looseBorrower(), owner()] : [owner(), looseBorrower()];
expect(composeRefusal(stacks, { manifest: manifestOpt })).toBeUndefined();
});

it.each(['last', 'preserve'])('refuses a dangling name a `strict: false` input carries (`%s`)', (manifestOpt) => {
const refused = composeRefusal([owner(instance({ name: 'sap_ariba' })), looseBorrower()], { manifest: manifestOpt });
expectComposedFinding(refused, 'which is not defined in connectors');
});

it('refuses a descriptor, and an instance with nothing to lend, that another input declares', () => {
const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' };
expectComposedFinding(composeRefusal([owner(descriptor), looseBorrower()]), 'which declares no provider');
expectComposedFinding(
composeRefusal([owner(instance({ auth: { type: 'none' } })), looseBorrower()]),
'which declares no auth credential',
);
});

it('judges a name two inputs declare in the flattened artifact against the later entry, the one the registry keeps', () => {
const strictPair = defineStack({
manifest: pluginManifest('com.acme.orders'),
datasources: [sapOrders()],
connectors: [instance()],
} as never);
const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' };
const refused = composeRefusal([strictPair, owner(descriptor)]);
expectComposedFinding(refused, 'which declares no provider in the entry the composed artifact keeps');
expect(String(refused?.issues?.[0])).toContain(
"Connector 'sap_s4' is declared more than once: 'com.acme.orders' (stack #0) declares an instance " +
"that can lend, and 'com.acme.sap' (stack #1) declares the later entry, which the runtime keeps. " +
'Rename or remove the later entry',
);
expect(String(refused?.issues?.[0])).not.toContain('Give it the provider');
expect(composeRefusal([owner(descriptor), strictPair])).toBeUndefined();
});

describe('a name two packages of a `preserve` artifact declare: the registry keeps both, the first registered lends', () => {
const strictPair = () =>
defineStack({
manifest: pluginManifest('com.acme.orders'),
datasources: [sapOrders()],
connectors: [instance()],
} as never);
const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' };

it.each([
['instance first', false],
['descriptor first', true],
])('refuses the borrow as ambiguous when one can lend and the other cannot — %s', (_order, reversed) => {
const stacks = reversed ? [owner(descriptor), strictPair()] : [strictPair(), owner(descriptor)];
const refused = composeRefusal(stacks, { manifest: 'preserve' });
expectComposedFinding(refused, 'which more than one package of the composed artifact declares, and they disagree');
const finding = String(refused?.issues?.[0]);
expect(finding).toContain("'com.acme.orders' (stack #");
expect(finding).toContain("declares an instance that can lend, and 'com.acme.sap' (stack #");
expect(finding).toContain('lends from whichever registers first');
expect(finding).not.toContain('which the runtime keeps');
});

it('CONTROL: two packages that each declare an instance that can lend compose, in either order', () => {
const other = owner(instance({ auth: { type: 'bearer', credentialRef: 'sap_token_2' } }));
expect(composeRefusal([strictPair(), other], { manifest: 'preserve' })).toBeUndefined();
expect(composeRefusal([other, strictPair()], { manifest: 'preserve' })).toBeUndefined();
});
});

it('keeps the per-stack prescription when no declaration of the name could lend', () => {
const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' };
const refused = composeRefusal([owner(descriptor), owner(instance({ auth: { type: 'none' } })), looseBorrower()]);
expectComposedFinding(refused, 'which declares no auth credential — there is nothing to borrow. Declare the');
});

describe('a nested multi-package input, whose collections live only in its package bodies', () => {
const other = () => defineStack({ manifest: pluginManifest('com.acme.other') } as never);
const nested = (...stacks: unknown[]) => composeStacks([...stacks, other()] as never, { manifest: 'preserve' });

it('CONTROL: a borrower resolves against an instance a package body of the nested input carries', () => {
const inner = nested(owner());
expect((inner as Record<string, unknown>).connectors).toBeUndefined();
expect(composeRefusal([inner, looseBorrower()], { manifest: 'preserve' })).toBeUndefined();
expect(composeRefusal([looseBorrower(), inner])).toBeUndefined();
});

it("judges a datasource a nested input's package body carries", () => {
// Hand-assembled: a composition would refuse the dangling name itself.
const inner = defineStack(
{
manifest: pluginManifest('com.acme.bundle'),
packages: [{ manifest: { ...pluginManifest('com.acme.orders'), datasources: [sapOrders('sap_missing')] } }],
} as never,
{ strict: false },
);
const refused = composeRefusal([owner(), inner], { manifest: 'preserve' });
expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 });
expect(refused?.issues).toEqual([
expect.stringMatching(
/^Datasource 'sap_orders' borrows the credential of connector 'sap_missing' \(external\.connector\), which is not defined in connectors\./,
),
]);
});

it("treats a package body and another input's top level as two slots, and names the body", () => {
const strictPair = defineStack({
manifest: pluginManifest('com.acme.orders'),
datasources: [sapOrders()],
connectors: [instance()],
} as never);
const refused = composeRefusal([nested(strictPair), owner({ name: 'sap_s4', label: 'SAP', type: 'api' })]);
expectComposedFinding(refused, 'which more than one package of the composed artifact declares, and they disagree');
expect(String(refused?.issues?.[0])).toContain(
"package 'com.acme.orders' of 'com.acme.other' (stack #0) declares an instance that can lend",
);
});

it('reads an entry the nested input carries in both halves once', () => {
// A nested artifact whose bodies did not reproduce its flattened half
// keeps both, holding the same entry objects in each.
const borrower = sapOrders('sap_missing');
const both = defineStack(
{
manifest: pluginManifest('com.acme.bundle'),
datasources: [borrower],
packages: [{ manifest: { ...pluginManifest('com.acme.orders'), datasources: [borrower] } }],
} as never,
{ strict: false },
);
const refused = composeRefusal([owner(), both]);
expect(refused?.issues).toHaveLength(1);
expect(refused?.message).toContain('composeStacks artifact cross-reference validation failed (1 issue):');
});
});

it('CONTROL: the per-stack strict refusal is unchanged — a strict input never carries a dangling name in', () => {
const refused = refusalOf({ manifest: pluginManifest('com.acme.orders'), datasources: [sapOrders()] });
expectOneFinding(refused, 'which is not defined in connectors');
expect(refused?.message).not.toContain('composeStacks');
});
});
Loading
Loading