From acf86150c1e49392809047caf2a9b7c50655cc96 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:38:52 +0000 Subject: [PATCH 1/2] fix(objectql): a residual top-level connector beside package bodies lends at boot The manifest service registered a multi-package stack's residual objects under the stack's manifest id, but not its connectors. The credential lender and the connector reconcile read connectors from the ObjectQL registry only, so a connector instance declared only on the top level of a mixed composed input was refused as undeclared at boot, although the composed check accepts a borrow of it. The residual's connectors now register the way its objects do: after every body, under the residual rule's id, on a stamped copy. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ihL2dhraZ7BwSanQPGEAb --- .../28032-residual-connector-lends-at-boot.md | 10 + .../manifest-service-residual-objects.test.ts | 55 ++++++ packages/objectql/src/plugin.ts | 50 ++++- ...ctor-credential-residual-connector.test.ts | 182 ++++++++++++++++++ 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 .changeset/28032-residual-connector-lends-at-boot.md create mode 100644 packages/services/service-automation/src/connector-credential-residual-connector.test.ts diff --git a/.changeset/28032-residual-connector-lends-at-boot.md b/.changeset/28032-residual-connector-lends-at-boot.md new file mode 100644 index 00000000000..ad19f2aa767 --- /dev/null +++ b/.changeset/28032-residual-connector-lends-at-boot.md @@ -0,0 +1,10 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a connector a composed stack declares only on its top level, beside package bodies that do not declare it, lends its credential at boot + +Clause-②: no +Metadata-protocol: none + +The engine's `manifest` service registers a multi-package stack's residual (top-level items no package body declares, ADR-0130 D4) objects under the stack's own `manifest.id`; it now registers the residual's connectors the same way. The connector credential lender and the ADR-0097 reconcile read the connectors from the ObjectQL registry only, so a residual connector instance used to be invisible to them at boot: the instance did not materialize, and a datasource whose `external.connector` names it had its borrow refused as undeclared until a dev reload, although `composeStacks(…, { manifest: 'preserve' })` accepts that borrow. Stacks whose top level repeats their bodies, and single-package stacks, are unchanged. diff --git a/packages/objectql/src/manifest-service-residual-objects.test.ts b/packages/objectql/src/manifest-service-residual-objects.test.ts index 664c832ba74..e19f1bd196e 100644 --- a/packages/objectql/src/manifest-service-residual-objects.test.ts +++ b/packages/objectql/src/manifest-service-residual-objects.test.ts @@ -277,3 +277,58 @@ describe('ADR-0130 D4 — a residual arriving after start()', () => { expect(bridged?._packageId).toBe(RELEASE_ID); }); }); + +/** + * [ADR-0015 D7] The residual's CONNECTORS reach the registry too: the + * connector credential lender and the ADR-0097 reconcile read + * `registry.listItems('connector')` alone, so a residual instance registered + * by the metadata door only could not lend at boot, while the composed check + * accepted a borrow of it. The borrow itself is pinned on a kernel in + * `service-automation` (`connector-credential-residual-connector.test.ts`). + */ +describe('ADR-0015 D7 — the manifest service registers the residual\'s connectors', () => { + const sap = (name = 'acme_sap') => ({ + name, + label: 'SAP', + type: 'api', + provider: 'openapi', + auth: { type: 'bearer', credentialRef: 'acme_sap_token' }, + }); + const connectorOwners = (ql: ObjectQL) => + (ql.registry.listItems('connector') as Array<{ name?: string; _packageId?: string; _packageVersion?: string }>) + .map((c) => [c.name, c._packageId, c._packageVersion]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))); + + it('registers a residual connector under the stack\'s manifest id and version, beside a body\'s own', async () => { + const residualEntry = sap(); + const { kernel } = await boot(registering('app', [ + residualStack(noteObj(), { connectors: [residualEntry] }, { connectors: [sap('acme_crm')] }), + ])); + expect(connectorOwners(engineOf(kernel))).toEqual([ + ['acme_crm', SERVICE_ID, '2.4.0'], + ['acme_sap', RELEASE_ID, '1.0.0'], + ]); + // The artifact's own entry is left as it arrived. + expect((residualEntry as { _packageId?: string })._packageId).toBeUndefined(); + }); + + it('registers a residual connector when the stack has no residual object', async () => { + const { kernel } = await boot(registering('app', [payload({ + manifest: { ...app, id: RELEASE_ID }, + objects: [caseObj(), accountObj()], + connectors: [sap()], + packages: [ + { manifest: { ...svc, objects: [caseObj()] } }, + { manifest: { ...app, objects: [accountObj()] } }, + ], + })])); + expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', RELEASE_ID, '1.0.0']]); + }); + + it('CONTROL: a top-level connector a body repeats is the body\'s, registered once', async () => { + const { kernel } = await boot(registering('app', [ + residualStack(noteObj(), { connectors: [sap()] }, { connectors: [sap()] }), + ])); + expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', SERVICE_ID, '2.4.0']]); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 4aa97979917..f34f4ac581c 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -153,6 +153,35 @@ function unclaimedTopLevelObjects( : undefined; } +/** + * The CONNECTORS of a multi-package stack's residual, and the id they are owned + * by — the residual rule's answer read the way {@link unclaimedTopLevelObjects} + * reads it, for the one other residual type a boot-time reader takes from the + * registry alone. + * + * Why the engine needs them (ADR-0015 D7): the connector credential lender and + * the ADR-0097 reconcile read `registry.listItems('connector')`, never the + * metadata service. A residual connector instance registered by the metadata + * door only was therefore invisible to both at boot — the instance did not + * materialize, and a datasource's `external.connector` borrow of it was refused + * as undeclared until a dev reload carried the artifact in — while the composed + * check (`composeStacks`) accepts that borrow, because the instance is declared. + * + * @returns `undefined` when the stack has no residual connector. + */ +function unclaimedTopLevelConnectors( + stack: unknown, +): { ownerId: string | undefined; ownerVersion: string | undefined; connectors: Record[] } | undefined { + const residual = unclaimedTopLevel(stack); + if (!residual) return undefined; + const connectors = residual.items + .filter((entry) => entry.type === 'connector') + .map((entry) => entry.item as Record); + return connectors.length > 0 + ? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, connectors } + : undefined; +} + /** * Options for ObjectQLPlugin. * @@ -771,8 +800,11 @@ export class ObjectQLPlugin implements Plugin { // no package body declares — as the residual rule answers it. See // {@link unclaimedTopLevelObjects}. const residual = unclaimedTopLevelObjects(artifact); + // …and its CONNECTORS, which the credential lender reads from the + // registry alone. See {@link unclaimedTopLevelConnectors}. + const residualConnectors = unclaimedTopLevelConnectors(artifact); - if (ordered.length === 0 && residual === undefined) { + if (ordered.length === 0 && residual === undefined && residualConnectors === undefined) { // An artifact that declared `packages: []` registers nothing. Said out // loud rather than returning quietly: "the install did nothing" is not // a state anyone should have to infer from an absence. @@ -846,6 +878,22 @@ export class ObjectQLPlugin implements Plugin { // bodies (a composed stack keeps one member's manifest) that body's // record must not be replaced. if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered); + // [ADR-0015 D7] The residual's connectors, the same way and in the same + // place: after every body, under the residual rule's id, through the + // registry verb a body's own connectors take (`registerApp`'s metadata + // collections). No body declares one of these names — that is what + // makes it residual — so none of them shadows a body's entry. + // Stamped as a residual object is: the version first, on a shallow + // copy, leaving the artifact's own entry as the registry leaves it. + if (residualConnectors) { + const { ownerId, ownerVersion, connectors } = residualConnectors; + for (const connector of connectors) { + const stamped = ownerVersion === undefined + ? { ...connector } + : applyProtection({ ...connector } as any, { packageId: ownerId, packageVersion: ownerVersion }); + ql.registry.registerItem('connector', stamped, 'name' as any, ownerId); + } + } // [#24472] The boot reported every same-name pair it found // (`kernel:ready`, below); an artifact arriving later is judged once it // has registered, for the pairs naming one of its packages on either diff --git a/packages/services/service-automation/src/connector-credential-residual-connector.test.ts b/packages/services/service-automation/src/connector-credential-residual-connector.test.ts new file mode 100644 index 00000000000..b23e57de10a --- /dev/null +++ b/packages/services/service-automation/src/connector-credential-residual-connector.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0015 D7 × ADR-0130 D4] A connector instance a composed stack declares + * only on its TOP LEVEL, beside package bodies that do not declare it — the + * stack's residual — lends its credential at boot, the way one a body + * declares does. + * + * ## The defect this pins + * + * `composeStacks([mixed, borrower], { manifest: 'preserve' })`, where `mixed` + * is an input that carries `packages[]` AND a top-level connector instance no + * body repeats, is accepted at build: the composed check finds the instance + * and judges that it can lend. At boot the residual rule registered that + * instance into the metadata service only, and the engine's `manifest` + * service registered the residual's OBJECTS only, so the lender — which reads + * the ObjectQL registry (`readDeclaredConnectorItems`) — did not see it, and + * the datasource's borrow was refused as undeclared until a dev reload + * carried the artifact in. Build and boot gave two answers. + * + * ## What each pin is for + * + * - the residual instance lends at boot, under the stack's own id, which is + * the owner the residual rule names; + * - the residual descriptor (no `provider`) is refused at boot as a + * descriptor, not as undeclared: the lender sees it and judges it; + * - CONTROL: an instance a package body declares lends, as it always did. + * + * The stack is booted through the `manifest` service, the way `AppPlugin` + * hands a config boot's stack to it: `{ ...bundle.manifest, ...bundle }`. The + * artifact door hands the engine the same stack. The values are obvious dummies. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, type IConnectorCredentialSource } from '@objectstack/types'; + +import { AutomationServicePlugin } from './plugin.js'; + +const REF = 'DUMMY_RESIDUAL_SAP_TOKEN'; +const DUMMY_ENV_VALUE = 'dummy-residual-dddd-0004'; + +const MIXED_ID = 'com.acme.bundle'; +const BODY_ID = 'com.acme.orders'; + +const pluginManifest = (id: string) => ({ + id, + namespace: id.split('.').pop(), + version: '1.0.0', + type: 'plugin', + name: id.split('.').pop(), +}); + +const instance = (over: Record = {}) => ({ + name: 'sap_s4', + label: 'SAP S/4HANA', + type: 'api', + provider: 'fake', + providerConfig: {}, + auth: { type: 'bearer', credentialRef: REF }, + ...over, +}); + +const sapOrders = () => ({ + name: 'sap_orders', + driver: 'odata', + config: { serviceUrl: 'https://sap.example.com/odata/v4/orders' }, + schemaMode: 'external', + external: { connector: 'sap_s4' }, +}); + +/** The borrowing input, which declares no connector of its own. */ +const looseBorrower = () => + defineStack({ manifest: pluginManifest('com.acme.ds'), datasources: [sapOrders()] } as never, { strict: false }); + +/** + * An input that carries `packages[]` and, on its top level only, `connectors` + * — the mixed shape. `bodyConnectors` lands on the package body instead. + */ +const mixed = (topLevel: unknown[] | undefined, bodyConnectors?: unknown[]) => + defineStack( + { + manifest: pluginManifest(MIXED_ID), + ...(topLevel ? { connectors: topLevel } : {}), + packages: [{ manifest: { ...pluginManifest(BODY_ID), ...(bodyConnectors ? { connectors: bodyConnectors } : {}) } }], + } as never, + { strict: false }, + ); + +/** What `AppPlugin.init` hands the `manifest` service. */ +const payload = (bundle: Record) => ({ ...(bundle.manifest as object), ...bundle }); + +const kernels: ObjectKernel[] = []; +const envKeys: string[] = []; +afterEach(async () => { + for (const k of kernels.splice(0)) await k.shutdown().catch(() => {}); + for (const key of envKeys.splice(0)) delete process.env[key]; +}); + +function setEnv(key: string, value: string): void { + envKeys.push(key); + process.env[key] = value; +} + +/** Boot the engine and the automation plugin; register `stack` through the `manifest` service at init. */ +async function boot(stack: Record) { + const kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false }); + kernels.push(kernel); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + } as Plugin); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory', credentialResolver: () => undefined })); + await kernel.use({ + name: 'test.stack', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql', 'com.objectstack.service-automation'], + async init(ctx: PluginContext) { + ctx.getService('automation').registerConnectorProvider('fake', (pctx: any) => ({ + def: { name: pctx.name, label: pctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] }, + handlers: { request: async () => ({ status: 200, ok: true, body: {} }) }, + })); + await ctx.getService<{ register(m: unknown): Promise }>('manifest').register(payload(stack)); + }, + } as Plugin); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + const source = kernel.getService(CONNECTOR_CREDENTIAL_SOURCE_SERVICE); + return { ql, source }; +} + +const connectorOwners = (ql: ObjectQL): Array<[unknown, unknown]> => + (ql.registry.listItems('connector') as Array<{ name?: unknown; _packageId?: unknown }>) + .map((c) => [c.name, c._packageId]); + +describe('[ADR-0015 D7] a connector a mixed composed input declares only on its top level', () => { + it('is accepted at build: the composed check judges that it can lend', () => { + expect(() => composeStacks([mixed([instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never)).not.toThrow(); + }); + + it('lends its credential at boot, registered under the stack\'s own id', async () => { + setEnv(REF, DUMMY_ENV_VALUE); + const stack = composeStacks([mixed([instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never) as Record; + const { ql, source } = await boot(stack); + const ownerId = (stack.manifest as { id?: string }).id; + const handle = await source.borrow('sap_s4'); + expect(handle.connector).toBe('sap_s4'); + await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } }); + expect(connectorOwners(ql)).toEqual([['sap_s4', ownerId]]); + }); + + it('is judged by the lender at boot when it cannot lend: a descriptor is refused as one, not as undeclared', async () => { + // Hand-assembled: the composed check refuses a borrow of a descriptor. + const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' }; + const stack = composeStacks([mixed([descriptor]), defineStack({ manifest: pluginManifest('com.acme.ds') } as never)] as never, { + manifest: 'preserve', + } as never) as Record; + const { ql, source } = await boot(stack); + const refused = await source.borrow('sap_s4').then(() => undefined, (err: unknown) => err as { status?: number; message?: string }); + expect(refused?.status).toBe(422); + expect(refused?.message).not.toContain('is not declared'); + expect(refused?.message).toContain('declares no provider'); + expect(connectorOwners(ql).map(([name]) => name)).toEqual(['sap_s4']); + }); + + it('CONTROL: an instance a package body declares lends at boot, owned by that body', async () => { + setEnv(REF, DUMMY_ENV_VALUE); + const stack = composeStacks([mixed(undefined, [instance()]), looseBorrower()] as never, { manifest: 'preserve' } as never) as Record; + const { ql, source } = await boot(stack); + expect(connectorOwners(ql)).toEqual([['sap_s4', BODY_ID]]); + await expect((await source.borrow('sap_s4')).get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } }); + }); +}); From 3ab7953653182999fccd0ef99b0228568e381cf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:31:47 +0000 Subject: [PATCH 2/2] test(service-automation): type the automation lookup in the residual-connector borrow test Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ihL2dhraZ7BwSanQPGEAb --- .../src/connector-credential-residual-connector.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/connector-credential-residual-connector.test.ts b/packages/services/service-automation/src/connector-credential-residual-connector.test.ts index b23e57de10a..495b8c0d94b 100644 --- a/packages/services/service-automation/src/connector-credential-residual-connector.test.ts +++ b/packages/services/service-automation/src/connector-credential-residual-connector.test.ts @@ -38,6 +38,7 @@ import { composeStacks, defineStack } from '@objectstack/spec'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, type IConnectorCredentialSource } from '@objectstack/types'; import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; const REF = 'DUMMY_RESIDUAL_SAP_TOKEN'; const DUMMY_ENV_VALUE = 'dummy-residual-dddd-0004'; @@ -125,7 +126,7 @@ async function boot(stack: Record) { version: '1.0.0', dependencies: ['com.objectstack.engine.objectql', 'com.objectstack.service-automation'], async init(ctx: PluginContext) { - ctx.getService('automation').registerConnectorProvider('fake', (pctx: any) => ({ + ctx.getService('automation').registerConnectorProvider('fake', (pctx: any) => ({ def: { name: pctx.name, label: pctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] }, handlers: { request: async () => ({ status: 200, ok: true, body: {} }) }, }));