From 0914e4d7dc8f6047a6b553f6de01d81172c57a04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:16:02 +0000 Subject: [PATCH 1/7] wip(spec): external.connector on a datasource (ADR-0015 D7) Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- packages/spec/src/data/datasource.test.ts | 109 ++++++++++++++++ packages/spec/src/data/datasource.zod.ts | 102 ++++++++++++++- .../src/stack-datasource-connector.test.ts | 119 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 74 +++++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/stack-datasource-connector.test.ts diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index b87e33fdce5..a3b5b589038 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -848,3 +848,112 @@ describe('datasource `password` guidance — following it parses', () => { expect(result.success).toBe(true); }); }); + +// ── ADR-0015 amendment D7 — `external.connector` ────────────────────────────── +// +// An API-backed datasource names a declared connector instance and borrows its +// credential (ruling 6486136301). The datasource-level half refuses the three +// pairings the key can never serve; whether the instance is declared is the +// stack's half (`stack-datasource-connector.test.ts`). +describe('external.connector — an API-backed datasource borrows a connector instance credential', () => { + const odata = { + name: 'sap_orders', + driver: 'odata', + config: { serviceUrl: 'https://sap.example.com/odata/v4/orders' }, + schemaMode: 'external', + } as const; + + const issuesAt = (raw: unknown, path: string) => { + const result = DatasourceSchema.safeParse(raw); + return result.success ? [] : result.error.issues.filter((i) => i.path.join('.') === path); + }; + + it('accepts the key on a plugin driver in external and validate-only modes', () => { + for (const schemaMode of ['external', 'validate-only'] as const) { + const result = DatasourceSchema.safeParse({ ...odata, schemaMode, external: { connector: 'sap_s4' } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + if (result.success) expect(result.data.external?.connector).toBe('sap_s4'); + } + }); + + it('is a declared key, not an unrecognized one', () => { + const result = ExternalDatasourceSettingsSchema.safeParse({ connector: 'sap_s4' }); + expect(result.success).toBe(true); + }); + + it('folds the near-miss spellings onto `connector`', () => { + for (const spelling of ['connectorName', 'connectorId', 'connectorRef', 'connectorInstance']) { + const result = DatasourceSchema.safeParse({ ...odata, external: { [spelling]: 'sap_s4' } }); + const messages = result.success ? [] : result.error.issues.map((i) => i.message); + expect(messages.join('\n'), spelling).toContain('connector'); + } + }); + + it('takes a connector name, so it refuses a name that is not snake_case', () => { + expect(issuesAt({ ...odata, external: { connector: 'SAP-S4' } }, 'external.connector')).toHaveLength(1); + }); + + it('refuses `connector` beside a bound `credentialsRef` — the two channels are exclusive', () => { + const hits = issuesAt( + { ...odata, external: { connector: 'sap_s4', credentialsRef: 'sys_secret:9f2c' } }, + 'external.connector', + ); + expect(hits).toHaveLength(1); + expect(hits[0]!.code).toBe('custom'); + expect(hits[0]!.message).toContain('two credential channels'); + }); + + it('an empty-string `credentialsRef` is not a binding, so it is not the second channel', () => { + const result = DatasourceSchema.safeParse({ ...odata, external: { connector: 'sap_s4', credentialsRef: '' } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('refuses `connector` on every built-in database driver, naming the driver', () => { + const builtins = [ + { driver: 'postgres', config: { host: 'db.internal', database: 'analytics', username: 'ro' } }, + { driver: 'mysql', config: { host: 'db.internal', database: 'analytics', username: 'ro' } }, + { driver: 'sqlite', config: { filename: 'warehouse.db' } }, + ]; + for (const { driver, config } of builtins) { + const hits = issuesAt( + { name: 'warehouse', driver, config, schemaMode: 'external', external: { connector: 'sap_s4' } }, + 'external.connector', + ); + expect(hits, driver).toHaveLength(1); + expect(hits[0]!.message).toContain(`'${driver}' driver connects to a database`); + expect(hits[0]!.message).toContain('external.credentialsRef'); + } + }); + + it('refuses `connector` on a managed datasource, prescribing the schema mode — not the generic managed-block deletion', () => { + const result = DatasourceSchema.safeParse({ ...odata, schemaMode: 'managed', external: { connector: 'sap_s4' } }); + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + const hits = issues.filter((i) => i.path.join('.') === 'external.connector'); + expect(hits).toHaveLength(1); + expect(hits[0]!.message).toContain("set `schemaMode: 'external'`"); + // The generic managed-block refusal does not also list it for deletion. + expect(issues.some((i) => i.path.join('.') === 'external')).toBe(false); + }); + + it('a managed built-in datasource gets the driver refusal alone — the mode advice would not fix it', () => { + const hits = issuesAt( + { name: 'analytics', driver: 'postgres', config: { host: 'db.internal', database: 'a', username: 'ro' }, external: { connector: 'sap_s4' } }, + 'external.connector', + ); + expect(hits).toHaveLength(1); + expect(hits[0]!.message).toContain("'postgres' driver connects to a database"); + }); + + it('a `connector` written at the datasource level is pointed into the `external` block', () => { + const result = DatasourceSchema.safeParse({ ...odata, external: {}, connector: 'sap_s4' }); + expect(result.success).toBe(false); + const messages = result.success ? [] : result.error.issues.map((i) => i.message); + expect(messages.join('\n')).toContain('it belongs in the `external` block'); + }); + + it('CONTROL: `credentialsRef` alone on a plugin driver still parses, unchanged', () => { + const result = DatasourceSchema.safeParse({ ...odata, external: { credentialsRef: 'sys_secret:9f2c' } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); +}); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index c49a9165b4d..f32280d29a3 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -272,6 +272,16 @@ export type SchemaMode = z.input; * `credentialsRef` alone is also valid on a managed datasource (#8153) — * the Studio wizard's `createDatasource` stores the secret in the secrets * store and keeps the reference here, whatever the schema mode. + * + * `connector` is the other credential channel (ADR-0015 amendment D7, ruling + * 6486136301): an API-backed datasource names a declared connector instance + * and borrows that instance's credential, so one Connect per vendor serves + * both flows and virtual objects. The two channels are exclusive, and the + * datasource-level refinement below refuses the pairings that can never work + * (both channels at once, a built-in database driver, a managed datasource); + * whether the named instance is declared is judged where the stack's + * `connectors` are visible ({@link collectDatasourceConnectorErrors} in + * `stack.zod.ts`). */ export const ExternalDatasourceSettingsSchema = strictObject( { @@ -283,6 +293,10 @@ export const ExternalDatasourceSettingsSchema = strictObject( allowwrite: 'allowWrites', credentials: 'credentialsRef', secretref: 'credentialsRef', + connectorname: 'connector', + connectorid: 'connector', + connectorref: 'connector', + connectorinstance: 'connector', timeoutms: 'queryTimeoutMs', querytimeout: 'queryTimeoutMs', }, @@ -347,11 +361,18 @@ export const ExternalDatasourceSettingsSchema = strictObject( credentialsRef: z.string().optional() .describe('Reference into the secrets store; never inline credentials. ' + 'Valid in every schemaMode — the one `external` key a managed datasource may carry.'), + connector: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional() + .describe('Name of a declared connector instance (a `connectors` entry with a `provider`) whose ' + + 'credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and ' + + '`signed` kinds alike, resolved per dispatch as for the connector itself. One Connect serves ' + + 'both the connector\'s actions and this datasource\'s records. Exclusive with ' + + '`credentialsRef`; refused on a built-in database driver and on a managed datasource.'), queryTimeoutMs: z.number().default(30_000) .describe('Hard cap on per-query execution time.'), }) .describe('External datasource settings: federation policy (schemaMode != "managed") ' - + 'plus the secrets-store credentials reference (valid in every schemaMode)'); + + 'plus the secrets-store credentials reference (valid in every schemaMode) or the connector ' + + 'instance an API-backed datasource borrows its credential from'); export type ExternalDatasourceSettings = z.input; /** Post-parse shape of {@link ExternalDatasourceSettings} — defaults applied, transforms run (ADR-0122). */ @@ -497,6 +518,48 @@ const CREDENTIALS_REF_MONGO_NO_USERNAME_REFUSED = + 'discrete fields with a `config.url` that names a user is a third valid shape; it is judged ' + 'by the URL-branch refusal, not by this one.)'; +/** + * The three pairings `external.connector` can never serve (ADR-0015 amendment + * D7). Each refuses at the authoring door instead of at the first read, where + * the runtime would otherwise pick one credential silently or present an HTTP + * credential to a database. + * + * - **both channels** — `connector` and a bound `credentialsRef` name two + * sources for one credential. The ruling made them exclusive rather than + * ranking them: a precedence rule would leave the loser written and + * ignored, the silent no-op the closed shapes in this module exist to stop. + * An empty-string `credentialsRef` is not a binding (the connect path's + * test is truthy), so it does not count as the second channel. + * - **a built-in driver** — every driver in `DRIVER_VOCABULARY` connects to a + * database with a database credential. A connector instance lends an HTTP + * credential (a bearer token, an API key, an OAuth grant, a request + * signer), which only an API-backed, plugin-contributed driver can present. + * - **a managed datasource** — an API-backed datasource's schema lives in the + * remote system, so `schemaMode: 'managed'` is the wrong mode, and the + * prescription is the mode, not the deletion the generic managed-block + * message would give. + */ +const CONNECTOR_WITH_CREDENTIALS_REF_REFUSED = + 'this datasource names two credential channels: `external.connector` borrows the credential of ' + + 'a declared connector instance, and `external.credentialsRef` binds a secret of its own. Exactly ' + + 'one of them supplies the credential. For an API-backed datasource whose vendor a connector ' + + 'instance already authorizes, keep `connector` and remove `credentialsRef`; to give the ' + + 'datasource a secret of its own, remove `connector`.'; + +function connectorOnBuiltinDriverRefusal(driverId: string): string { + return `\`external.connector\` lends a connector instance's HTTP credential (a bearer token, an ` + + `API key, an OAuth grant or a request signer), which only an API-backed driver presents; the ` + + `'${driverId}' driver connects to a database with a database credential. Bind that secret with ` + + '`external.credentialsRef` (the Setup → Datasources connection form mints the handle) and ' + + 'remove `connector`.'; +} + +const CONNECTOR_ON_MANAGED_REFUSED = + '`external.connector` belongs to an API-backed datasource, whose schema lives in the remote ' + + "system and is never ObjectStack's to manage: set `schemaMode: 'external'` (or " + + "`'validate-only'` to warn on drift instead of failing) on this datasource. A managed " + + "datasource's `external` block may carry only `credentialsRef`."; + /** * Replay a driver-config parse onto the datasource's own issue list (#4410). * @@ -549,6 +612,10 @@ export const DatasourceSchema = lazySchema(() => strictObject( filename: belongsInConfig('filename'), url: belongsInConfig('url'), connectionString: belongsInConfig('connectionString', 'url'), + connector: + '`connector` is not a datasource key — it belongs in the `external` block, where it names ' + + 'the declared connector instance whose credential this API-backed datasource borrows ' + + '(e.g. `external: { connector: "sap_s4" }`).', // Not "move it into `config`": every driver contract refuses // `config.password` (and a `user:password@` URL), so that prescription // led straight into a second refusal. `external.credentialsRef` is valid @@ -772,6 +839,35 @@ export const DatasourceSchema = lazySchema(() => strictObject( } } + // ADR-0015 amendment D7 — the pairings `external.connector` can never serve. + // Whether the named instance is declared needs the stack's `connectors`, so + // that half runs in `stack.zod.ts` (`collectDatasourceConnectorErrors`). + if (ds.external?.connector !== undefined) { + if (ds.external.credentialsRef) { + ctx.addIssue({ + code: 'custom', + path: ['external', 'connector'], + message: CONNECTOR_WITH_CREDENTIALS_REF_REFUSED, + }); + } + const builtinDriver = resolveDriverId(ds.driver); + if (builtinDriver !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['external', 'connector'], + message: connectorOnBuiltinDriverRefusal(builtinDriver), + }); + } else if (ds.schemaMode === 'managed') { + // Skipped on a built-in driver: that refusal already says to drop the + // key, and this one would prescribe a mode the key still cannot use. + ctx.addIssue({ + code: 'custom', + path: ['external', 'connector'], + message: CONNECTOR_ON_MANAGED_REFUSED, + }); + } + } + if (ds.schemaMode !== 'managed' && !ds.external) { ctx.addIssue({ code: 'custom', @@ -818,7 +914,9 @@ let parsedEmptyExternal: ExternalDatasourceSettingsParsed | undefined; function managedFederationContentKeys(external: ExternalDatasourceSettingsParsed): string[] { const baseline = (parsedEmptyExternal ??= ExternalDatasourceSettingsSchema.parse({})); return Object.keys(external) - .filter((key) => key !== 'credentialsRef') + // `connector` on a managed datasource has its own refusal, which prescribes + // the schema mode rather than the deletion this list's message gives. + .filter((key) => key !== 'credentialsRef' && key !== 'connector') .filter((key) => !sameParsedValue( (external as Record)[key], (baseline as Record)[key], diff --git a/packages/spec/src/stack-datasource-connector.test.ts b/packages/spec/src/stack-datasource-connector.test.ts new file mode 100644 index 00000000000..7c92461215d --- /dev/null +++ b/packages/spec/src/stack-datasource-connector.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A datasource's `external.connector` names a connector instance the stack + * declares (ADR-0015 amendment D7, ruling 6486136301: "refused at build when + * the named instance is not declared"). + * + * ## What is pinned + * + * - `defineStack` refuses, with the cross-reference envelope + * (`STACK_CROSS_REFERENCE_INVALID`, 422), a datasource whose `connector` + * names no `connectors` entry, an entry with no `provider` (a catalog + * descriptor), or an instance with no `auth` / `auth: { type: 'none' }`; + * - each finding names the datasource and the connector; + * - the check needs no object: a stack with no `objects` is judged too; + * - CONTROL: a datasource naming a provider-bound instance with a credential + * is accepted, and so is one naming a disabled instance; + * - CONTROL: a datasource with no `connector` is untouched by the rule. + * + * 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'; + +const manifest = { id: 'com.example.erp', name: 'erp', version: '1.0.0', type: 'app' as const }; + +const instance = (over: Record = {}) => ({ + name: 'sap_s4', + label: 'SAP S/4HANA', + type: 'api', + provider: 'openapi', + providerConfig: { spec: 'https://sap.example.com/openapi.json' }, + auth: { type: 'bearer', credentialRef: 'sap_token' }, + ...over, +}); + +const sapOrders = (connector = 'sap_s4') => ({ + name: 'sap_orders', + driver: 'odata', + config: { serviceUrl: 'https://sap.example.com/odata/v4/orders' }, + schemaMode: 'external', + external: { connector }, +}); + +type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + +const refusalOf = (raw: Record): Envelope | undefined => { + try { + defineStack(raw as never); + return undefined; + } catch (err) { + return err as Envelope; + } +}; + +const SUBJECT = "Datasource 'sap_orders' borrows the credential of connector 'sap_s4' (external.connector)"; + +function expectOneFinding(refused: Envelope | undefined, fragment: string): void { + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + const hits = (refused?.issues ?? []).filter((i) => i.startsWith(SUBJECT)); + expect(hits).toHaveLength(1); + expect(hits[0]).toContain(fragment); +} + +describe('a datasource borrows the credential of a connector instance its stack declares', () => { + it('refuses a connector name the stack does not declare', () => { + const refused = refusalOf({ manifest, datasources: [sapOrders()], connectors: [instance({ name: 'sap_ariba' })] }); + expectOneFinding(refused, 'which is not defined in connectors'); + }); + + it('refuses it in a stack that declares no connectors at all', () => { + expectOneFinding(refusalOf({ manifest, datasources: [sapOrders()] }), 'which is not defined in connectors'); + }); + + it('refuses a catalog descriptor — an entry with no provider holds no credential', () => { + const descriptor = { name: 'sap_s4', label: 'SAP S/4HANA', type: 'api' }; + const refused = refusalOf({ manifest, datasources: [sapOrders()], connectors: [descriptor] }); + expectOneFinding(refused, 'which declares no provider'); + }); + + it('refuses an instance with no auth, and one with `auth: { type: none }`', () => { + const { auth: _auth, ...withoutAuth } = instance(); + for (const connector of [withoutAuth, instance({ auth: { type: 'none' } })]) { + const refused = refusalOf({ manifest, datasources: [sapOrders()], connectors: [connector] }); + expectOneFinding(refused, 'which declares no auth credential'); + } + }); + + it('judges a stack with objects too, and names each borrowing datasource once', () => { + const refused = refusalOf({ + manifest, + objects: [{ name: 'erp_note', label: 'Note', fields: { title: { type: 'text', label: 'Title' } } }], + datasources: [sapOrders('sap_missing'), { ...sapOrders(), name: 'sap_invoices' }], + connectors: [instance()], + }); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + const hits = (refused?.issues ?? []).filter((i) => i.includes('(external.connector)')); + expect(hits).toEqual([ + expect.stringContaining("Datasource 'sap_orders' borrows the credential of connector 'sap_missing'"), + ]); + }); + + it('CONTROL: a provider-bound instance with a credential is accepted', () => { + const stack = defineStack({ manifest, datasources: [sapOrders()], connectors: [instance()] } as never); + expect(stack.datasources?.[0]?.external?.connector).toBe('sap_s4'); + }); + + it('CONTROL: a disabled instance is accepted — the switch turns the vendor off for actions and records alike', () => { + expect(refusalOf({ manifest, datasources: [sapOrders()], connectors: [instance({ enabled: false })] })).toBeUndefined(); + }); + + it('CONTROL: a datasource with no connector is untouched by the rule', () => { + const plain = { ...sapOrders(), external: { credentialsRef: 'sys_secret:9f2c' } }; + expect(refusalOf({ manifest, datasources: [plain] })).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index f2dd6fb42d9..c8cc7c64428 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -4371,6 +4371,77 @@ function collectExternalBindingDatasourceErrors(config: ObjectStackDefinition): return errors; } +/** + * [ADR-0015 amendment D7] A datasource's `external.connector` must name a + * connector instance this stack declares: a `connectors` entry with a + * `provider` and an `auth` that is not `none`. + * + * The datasource borrows that instance's credential, so a name this stack + * cannot resolve would leave an API-backed datasource with no credential at + * all, found by its first read as an upstream 401. Refused here instead (the + * ruling: "refused at build when the named instance is not declared"), with + * three findings, because the fixes differ: + * + * - no `connectors` entry of that name — declare the instance or correct the + * name; + * - an entry with no `provider` — a catalog descriptor (ADR-0097), which is + * never materialized and holds no credential; + * - an instance with no `auth`, or `auth: { type: 'none' }` — nothing to + * lend; an unauthenticated upstream needs no `connector` on the datasource. + * + * Resolved against the stack's own `connectors`, like a job's mapping + * reference: a datasource is package-authored, and a reference its own stack + * cannot resolve is an authoring defect the package would otherwise ship. A + * connector a plugin registers at runtime is not a declared instance and has + * no connection record to share. A disabled instance (`enabled: false`) is + * accepted: the switch turns the vendor off for its actions and its records + * alike, which is the one-grant behaviour D7 asks for, not a dangling name. + * Runs before the object-count early return: it needs no object. + */ +function collectDatasourceConnectorErrors(config: ObjectStackDefinition): string[] { + const errors: string[] = []; + const datasources = Array.isArray(config.datasources) ? config.datasources : []; + const borrowing = datasources.filter( + (ds) => isRecord(ds) && isRecord(ds.external) && typeof ds.external.connector === 'string', + ); + if (borrowing.length === 0) return errors; + const connectors = new Map>(); + for (const entry of Array.isArray(config.connectors) ? config.connectors : []) { + if (isRecord(entry) && typeof entry.name === 'string') connectors.set(entry.name, entry); + } + for (const ds of borrowing) { + const name = String((ds.external as Record).connector); + const entry = connectors.get(name); + const subject = `Datasource '${String(ds.name)}' borrows the credential of connector '${name}' (external.connector)`; + if (entry === undefined) { + errors.push( + `${subject}, which is not defined in connectors. Declare the connector instance it borrows ` + + `from (a connectors entry named '${name}' with a provider and an auth credential, e.g. ` + + `auth: { type: 'bearer', credentialRef }) or correct the name.`, + ); + continue; + } + if (typeof entry.provider !== 'string' || entry.provider.length === 0) { + errors.push( + `${subject}, which declares no provider — a connector entry with no provider is a catalog ` + + `descriptor, never materialized, and holds no credential to lend (ADR-0097). Give it the ` + + `provider that serves this vendor and an auth credential, or name a connector instance ` + + `that has one.`, + ); + continue; + } + const auth = entry.auth; + if (!isRecord(auth) || auth.type === 'none') { + errors.push( + `${subject}, which declares no auth credential — there is nothing to borrow. Declare the ` + + `instance's auth (e.g. auth: { type: 'bearer', credentialRef }), or, if the upstream ` + + `needs no credential, remove external.connector from the datasource.`, + ); + } + } + return errors; +} + /** * [ADR-0015 §4.2 · #25211] {@link collectExternalBindingDatasourceErrors} over * a COMPOSITION: an `external` object of one input bound to a datasource @@ -4449,6 +4520,9 @@ function validateCrossReferences( // Same placement: the development boot's demo accounts resolve against the // stack's positions and permission sets, and need no object either. errors.push(...collectDemoStaffErrors(config)); + // Same placement: a datasource's `external.connector` resolves against the + // stack's connectors and needs no object either (ADR-0015 amendment D7). + errors.push(...collectDatasourceConnectorErrors(config)); // Same placement: the names an object gives its related lists (`relatedLists`, // a field group's `relatedList`) resolve against the objects of the stack AND // its package bodies, so an artifact whose objects live only in package From bba61d9553d24df4bd58bc3b92ce93966fbdcdc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:18:35 +0000 Subject: [PATCH 2/7] wip(spec): ADR-0015 D7 note, liveness row, changeset Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- .../27742-datasource-external-connector.md | 26 ++++++++++++++ .../0015-external-datasource-federation.md | 36 +++++++++++++++++-- packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/datasource.json | 6 ++++ packages/spec/src/data/datasource.test.ts | 2 +- 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .changeset/27742-datasource-external-connector.md diff --git a/.changeset/27742-datasource-external-connector.md b/.changeset/27742-datasource-external-connector.md new file mode 100644 index 00000000000..6080dc249b7 --- /dev/null +++ b/.changeset/27742-datasource-external-connector.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): an API-backed datasource names a declared connector instance in `external.connector` and borrows its credential + +Clause-②: yes +Metadata-protocol: added + +A datasource over a vendor API (a plugin-contributed driver such as OData) can now authenticate with the credential of a connector instance the stack already declares, instead of a second secret of its own: + +```ts +connectors: [{ name: 'sap_s4', label: 'SAP S/4HANA', type: 'api', provider: 'openapi', + providerConfig: { spec: './sap-openapi.json' }, auth: { type: 'bearer', credentialRef: 'sap_token' } }], +datasources: [{ name: 'sap_orders', driver: 'odata', config: { serviceUrl: '…' }, + schemaMode: 'external', external: { connector: 'sap_s4' } }], +``` + +One Connect per vendor then serves both the connector's flow actions and the datasource's virtual objects (ADR-0015 amendment D7). The key is refused where it cannot work, each refusal naming the fix: + +- beside a bound `external.credentialsRef` — the two credential channels are exclusive; +- on a built-in database driver (`postgres`, `mysql`, `sqlite`, `mongodb`, …) — a connector lends an HTTP credential; bind the database secret with `credentialsRef`; +- on a managed datasource — set `schemaMode: 'external'` (or `'validate-only'`); +- at `defineStack` (and so `os validate`) when the stack declares no `connectors` entry of that name, when the entry has no `provider` (a catalog descriptor), or when the instance declares no `auth` credential. + +Nothing changes for an existing datasource: `credentialsRef` keeps its meaning in every schema mode. The runtime that hands the instance's credential to the driver ships separately; until it does, the key is validated and not yet read. diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md index 7a0f6d67a61..15a250e2191 100644 --- a/docs/adr/0015-external-datasource-federation.md +++ b/docs/adr/0015-external-datasource-federation.md @@ -4,7 +4,36 @@ **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source of truth per metadata type) **Consumers**: `@objectstack/spec`, `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugins/driver-sql`, `@objectstack/services/service-ai`, `@objectstack/cli`, `../objectui` -**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below. +**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below. D7 (2026-10-29, ruling 6486136301 on #27298) — an API-backed datasource may borrow a declared connector instance's credential through `external.connector`; see the D7 note below. + +> **Amendment note D7 (2026-10-29, ruling 6486136301, letter A) — a second credential channel.** +> §10 says credentials reach a datasource only through `credentialsRef`. That stays true for a +> database. An **API-backed** datasource (a plugin-contributed driver over a vendor API, such as +> OData) may instead name a declared connector instance in `external.connector` and borrow that +> instance's credential — static, `oauth2`, `app-credential` and `signed` kinds alike — through +> ADR-0143 D4's per-dispatch resolver. One Connect per vendor then serves both the connector's +> actions (flows) and the datasource's records (virtual objects), and one reconnect restores both. +> The contract (`packages/spec/src/data/datasource.zod.ts#ExternalDatasourceSettingsSchema`): +> +> - `connector` and `credentialsRef` are exclusive; a datasource carrying both is refused, rather +> than one silently winning. +> - `connector` is refused on a built-in database driver (it lends an HTTP credential) and on a +> managed datasource (an API-backed datasource's schema is the remote system's). +> - The named instance must be declared in the same stack: a `connectors` entry with a +> `provider` and an `auth` other than `none`. Anything else is refused at build +> (`packages/spec/src/stack.zod.ts#collectDatasourceConnectorErrors`). +> +> Execution identity is not reopened: ADR-0143 Q1 = A (organization-shared) applies to the +> virtual-object read as to the flow dispatch, and object permissions and RLS stay the only gate +> on what a user sees. A datasource stays environment-scoped (§10). The connection it borrows is +> selected per dispatch by the request's organization, and a read that carries no organization +> (a boot-time drift check, a schedule) uses the instance's connection only when exactly one exists +> (ADR-0143 D4). Under the ruled single-organization posture (#27230 S1: one environment, one +> organization), that is always exactly one, so the environment-level datasource and the +> `(organization, connector instance)` connection record meet without a new rule. A shared +> environment hosting several organizations is outside S1: there a no-organization read is +> refused, never answered from an arbitrary organization's grant. Not taken: a second auth block +> on the datasource (two grants per vendor that drift), and a static key for OAuth sources. > **Amendment note (2026-10-27, ADR-0143) — attribution only, no decision changes.** ADR-0018, > ADR-0022, ADR-0023, ADR-0024 and ADR-0097 put OAuth2 refresh and per-tenant connection lifecycle @@ -706,7 +735,10 @@ managed Postgres for low-latency joins. - **Credentials** never appear in metadata artefacts. `credentialsRef` points at `service-secrets`; the secrets service is the only - component that resolves the actual connection string. + component that resolves the actual connection string. *(Amended by + D7, see the note at the top: an API-backed datasource may instead + borrow a declared connector instance's credential through + `external.connector`.)* - **Connection pools** default conservatively for external mode (`max: 5`) to protect the remote system; `pool` may be overridden. - **Timeouts** are enforced at the driver. AI tool calls additionally diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index ed9e5f57555..9b3731b9d4e 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -271,6 +271,7 @@ "data/ExternalColumn:suggestedFieldType", "data/ExternalDatasourceSettings:allowWrites", "data/ExternalDatasourceSettings:allowedSchemas", + "data/ExternalDatasourceSettings:connector", "data/ExternalDatasourceSettings:credentialsRef", "data/ExternalDatasourceSettings:queryTimeoutMs", "data/ExternalDatasourceSettings:validation", diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json index 2a3e6c2fba7..d21b19305c6 100644 --- a/packages/spec/liveness/datasource.json +++ b/packages/spec/liveness/datasource.json @@ -144,6 +144,12 @@ "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts#attemptConnect (`const credentialsRef = record.external?.credentialsRef`, resolved through the configured secret resolver into a `secret` spread onto the factory call and never stored on the record — three distinct failure states are named rather than swallowed: no secret store configured, resolver threw, resolved to nothing)", "note": "dereferenced through the SecretBinder to cleartext for the duration of one connect; never persisted or logged (ADR-0015 Addendum). 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `:457` had rotted onto a docblock about threading a value through an instance field rather than the ConnectResult, ~95 lines above the read. Re-closed by hand against c459da6bc; DATED for the first time." }, + "connector": { + "status": "planned", + "verifiedAt": "2026-10-29", + "evidence": "packages/spec/src/stack.zod.ts#collectDatasourceConnectorErrors (a name the stack does not declare, a catalog descriptor or an instance with no auth credential is REFUSED at build); packages/spec/src/data/datasource.zod.ts#DatasourceSchema (beside a bound `credentialsRef`, on a built-in database driver and on a managed datasource it is REFUSED at parse)", + "note": "PLANNED, not `dead`: ADR-0015 amendment D7 (ruling 6486136301) landed the spec half first, and the runtime half — the datasource connection service handing the named instance's credential, resolved per dispatch through ADR-0143 D4's resolver, to the API-backed driver — is its own server card, blocked by this one. Until it lands no runtime reads the key; every pairing that could never work is already refused at authoring, and the only accepted shape is a plugin-contributed driver, none of which ships in this repository today. Flip to `live` with the connection-service anchor when the server card lands, in the same release." + }, "queryTimeoutMs": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index a3b5b589038..92a7621118a 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -885,7 +885,7 @@ describe('external.connector — an API-backed datasource borrows a connector in for (const spelling of ['connectorName', 'connectorId', 'connectorRef', 'connectorInstance']) { const result = DatasourceSchema.safeParse({ ...odata, external: { [spelling]: 'sap_s4' } }); const messages = result.success ? [] : result.error.issues.map((i) => i.message); - expect(messages.join('\n'), spelling).toContain('connector'); + expect(messages.join('\n'), spelling).toContain(`\`${spelling}\` → \`connector\``); } }); From b4ba470c4e6c4a2eff775fc134657b8ea09f50e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:22:42 +0000 Subject: [PATCH 3/7] chore(spec): regenerate datasource reference and liveness counts Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- content/docs/references/data/datasource.mdx | 6 ++++-- packages/spec/liveness/state-counts/datasource.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index c000603c1cc..b70c9029185 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -38,7 +38,7 @@ const result = DatasourceSchema.parse(data); | **active** | `boolean` | optional (default: `true`) | Is datasource enabled | | **autoConnect** | `boolean` | optional (default: `false`) | Force a live driver connection at boot even when managed + unrouted (ADR-0062 D2). | | **schemaMode** | `Enum<'managed' \| 'external' \| 'validate-only'>` | optional (default: `"managed"`) | Schema ownership mode | -| **external** | `{ allowedSchemas?: string[]; allowWrites?: boolean; validation?: object; credentialsRef?: string; … }` | optional | External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode) | +| **external** | `{ allowedSchemas?: string[]; allowWrites?: boolean; validation?: object; credentialsRef?: string; … }` | optional | External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode) or the connector instance an API-backed datasource borrows its credential from | | **origin** | `Enum<'code' \| 'runtime'>` | optional (default: `"code"`) | Datasource provenance (server-managed, read-only) | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -75,6 +75,7 @@ const result = DatasourceSchema.parse(data); | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | | **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | +| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved per dispatch as for the connector itself. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | @@ -106,7 +107,7 @@ Underlying driver identifier ## ExternalDatasourceSettings -External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode) +External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode) or the connector instance an API-backed datasource borrows its credential from ### Properties @@ -116,6 +117,7 @@ External datasource settings: federation policy (schemaMode != "managed") plus t | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | | **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | +| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved per dispatch as for the connector itself. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | ### Nested Shape: `ExternalDatasourceSettings.validation` diff --git a/packages/spec/liveness/state-counts/datasource.md b/packages/spec/liveness/state-counts/datasource.md index 2c7559a73ef..7a6ea194af5 100644 --- a/packages/spec/liveness/state-counts/datasource.md +++ b/packages/spec/liveness/state-counts/datasource.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | +| `datasource` | 30 | 0 | 0 | 0 | 1 | 31 | From 9a7cb61341b8cdf3e620b6526b87ec9ee86a68f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:24:16 +0000 Subject: [PATCH 4/7] fix(spec): the connector describe says how static kinds resolve Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- content/docs/references/data/datasource.mdx | 4 ++-- packages/spec/src/data/datasource.zod.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index b70c9029185..e570e01699f 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -75,7 +75,7 @@ const result = DatasourceSchema.parse(data); | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | | **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | -| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved per dispatch as for the connector itself. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | +| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved the way the connector resolves it. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | @@ -117,7 +117,7 @@ External datasource settings: federation policy (schemaMode != "managed") plus t | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | | **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | -| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved per dispatch as for the connector itself. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | +| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved the way the connector resolves it. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | ### Nested Shape: `ExternalDatasourceSettings.validation` diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index f32280d29a3..e0f177402a5 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -364,7 +364,7 @@ export const ExternalDatasourceSettingsSchema = strictObject( connector: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional() .describe('Name of a declared connector instance (a `connectors` entry with a `provider`) whose ' + 'credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and ' - + '`signed` kinds alike, resolved per dispatch as for the connector itself. One Connect serves ' + + '`signed` kinds alike, resolved the way the connector resolves it. One Connect serves ' + 'both the connector\'s actions and this datasource\'s records. Exclusive with ' + '`credentialsRef`; refused on a built-in database driver and on a managed datasource.'), queryTimeoutMs: z.number().default(30_000) From fcea4b8bfc1ad20e2c9b76b353fff093bb2f338c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:28:31 +0000 Subject: [PATCH 5/7] test(spec): the credential-name ratchet allow-lists external.connector, a connector name Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- packages/spec/src/kernel/metadata-credential-fields.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/spec/src/kernel/metadata-credential-fields.test.ts b/packages/spec/src/kernel/metadata-credential-fields.test.ts index 5a7360296a3..5dc577cb810 100644 --- a/packages/spec/src/kernel/metadata-credential-fields.test.ts +++ b/packages/spec/src/kernel/metadata-credential-fields.test.ts @@ -47,6 +47,9 @@ import { const NOT_A_CREDENTIAL: Readonly> = { 'datasource:external.credentialsRef': 'a reference into the secrets store, never the credential itself (the schema says: never inline credentials)', + 'datasource:external.connector': + 'the NAME of a declared connector instance (snake_case, pattern-checked); the credential it lends stays on ' + + 'that instance, referenced by its own `auth.credentialRef` (ADR-0015 amendment D7)', }; const CREDENTIAL_WORDS = new Set([ From 3ba8a678f5d96d3bf8d8032581578e2e8101e5f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:48:49 +0000 Subject: [PATCH 6/7] fix(spec): the connector describe and ADR-0015 D7 note name the auth kinds that exist and how each resolves; anchor D7 Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- docs/adr/0015-external-datasource-federation.md | 7 +++++-- packages/spec/src/data/datasource.zod.ts | 16 ++++++++++------ packages/spec/src/stack.zod.ts | 7 ++++--- ...ages__spec__src__data__datasource.zod.ts.json | 5 +++++ .../packages__spec__src__stack.zod.ts.json | 5 +++++ 5 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 scripts/adr-anchors/packages__spec__src__data__datasource.zod.ts.json create mode 100644 scripts/adr-anchors/packages__spec__src__stack.zod.ts.json diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md index 15a250e2191..dc6a2451e30 100644 --- a/docs/adr/0015-external-datasource-federation.md +++ b/docs/adr/0015-external-datasource-federation.md @@ -10,8 +10,11 @@ > §10 says credentials reach a datasource only through `credentialsRef`. That stays true for a > database. An **API-backed** datasource (a plugin-contributed driver over a vendor API, such as > OData) may instead name a declared connector instance in `external.connector` and borrow that -> instance's credential — static, `oauth2`, `app-credential` and `signed` kinds alike — through -> ADR-0143 D4's per-dispatch resolver. One Connect per vendor then serves both the connector's +> instance's credential — whichever kind its `auth` declares (`ConnectorInstanceAuthSchema`: +> `bearer`, `api-key` and `basic` today; ADR-0143 D2 adds the grant-bearing `oauth2`, +> `app-credential` and `signed`), resolved as ADR-0143 D4 resolves the instance's own: a static kind +> once at materialization, a grant-bearing kind per dispatch. The datasource connection service hands +> it to the driver (the runtime half, #27746). One Connect per vendor then serves both the connector's > actions (flows) and the datasource's records (virtual objects), and one reconnect restores both. > The contract (`packages/spec/src/data/datasource.zod.ts#ExternalDatasourceSettingsSchema`): > diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index e0f177402a5..aa9c6ddd917 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -276,7 +276,9 @@ export type SchemaMode = z.input; * `connector` is the other credential channel (ADR-0015 amendment D7, ruling * 6486136301): an API-backed datasource names a declared connector instance * and borrows that instance's credential, so one Connect per vendor serves - * both flows and virtual objects. The two channels are exclusive, and the + * both flows and virtual objects. The credential is whatever the instance's + * `auth` declares, resolved as ADR-0143 D4 resolves the instance's own: a + * static kind once at materialization, a grant-bearing kind per dispatch. The two channels are exclusive, and the * datasource-level refinement below refuses the pairings that can never work * (both channels at once, a built-in database driver, a managed datasource); * whether the named instance is declared is judged where the stack's @@ -363,8 +365,9 @@ export const ExternalDatasourceSettingsSchema = strictObject( + 'Valid in every schemaMode — the one `external` key a managed datasource may carry.'), connector: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional() .describe('Name of a declared connector instance (a `connectors` entry with a `provider`) whose ' - + 'credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and ' - + '`signed` kinds alike, resolved the way the connector resolves it. One Connect serves ' + + 'credential this API-backed datasource borrows — whichever kind the instance\'s `auth` ' + + 'declares (`bearer`, `api-key` or `basic`; ADR-0143 D2 adds the grant-bearing kinds), resolved ' + + 'the way the connector resolves it. One Connect serves ' + 'both the connector\'s actions and this datasource\'s records. Exclusive with ' + '`credentialsRef`; refused on a built-in database driver and on a managed datasource.'), queryTimeoutMs: z.number().default(30_000) @@ -532,8 +535,9 @@ const CREDENTIALS_REF_MONGO_NO_USERNAME_REFUSED = * test is truthy), so it does not count as the second channel. * - **a built-in driver** — every driver in `DRIVER_VOCABULARY` connects to a * database with a database credential. A connector instance lends an HTTP - * credential (a bearer token, an API key, an OAuth grant, a request - * signer), which only an API-backed, plugin-contributed driver can present. + * credential (a bearer token, an API key, basic auth; ADR-0143 D2's + * grant-bearing kinds alike), which only an API-backed, plugin-contributed + * driver can present. * - **a managed datasource** — an API-backed datasource's schema lives in the * remote system, so `schemaMode: 'managed'` is the wrong mode, and the * prescription is the mode, not the deletion the generic managed-block @@ -548,7 +552,7 @@ const CONNECTOR_WITH_CREDENTIALS_REF_REFUSED = function connectorOnBuiltinDriverRefusal(driverId: string): string { return `\`external.connector\` lends a connector instance's HTTP credential (a bearer token, an ` - + `API key, an OAuth grant or a request signer), which only an API-backed driver presents; the ` + + `API key or basic auth), which only an API-backed driver presents; the ` + `'${driverId}' driver connects to a database with a database credential. Bind that secret with ` + '`external.credentialsRef` (the Setup → Datasources connection form mints the handle) and ' + 'remove `connector`.'; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index c8cc7c64428..7beb89b85e6 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -4372,9 +4372,10 @@ function collectExternalBindingDatasourceErrors(config: ObjectStackDefinition): } /** - * [ADR-0015 amendment D7] A datasource's `external.connector` must name a - * connector instance this stack declares: a `connectors` entry with a - * `provider` and an `auth` that is not `none`. + * [ADR-0015 amendment D7 · ADR-0143] A datasource's `external.connector` must + * name a connector instance this stack declares: a `connectors` entry with a + * `provider` and an `auth` that is not `none` — the ADR-0143 instance auth + * contract whose credential the datasource borrows. * * The datasource borrows that instance's credential, so a name this stack * cannot resolve would leave an API-backed datasource with no credential at diff --git a/scripts/adr-anchors/packages__spec__src__data__datasource.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__datasource.zod.ts.json new file mode 100644 index 00000000000..5e3409c78d3 --- /dev/null +++ b/scripts/adr-anchors/packages__spec__src__data__datasource.zod.ts.json @@ -0,0 +1,5 @@ +{ + "file": "packages/spec/src/data/datasource.zod.ts", + "adrs": ["ADR-0015", "ADR-0143"], + "invariant": "ADR-0015 amendment D7: `external.connector` and `external.credentialsRef` are two exclusive credential channels — never a precedence that leaves one written and ignored. `connector` lends an ADR-0143 connector-instance credential (an HTTP credential), so it is refused beside a bound `credentialsRef`, on a built-in database driver and on a managed datasource." +} diff --git a/scripts/adr-anchors/packages__spec__src__stack.zod.ts.json b/scripts/adr-anchors/packages__spec__src__stack.zod.ts.json new file mode 100644 index 00000000000..47039676812 --- /dev/null +++ b/scripts/adr-anchors/packages__spec__src__stack.zod.ts.json @@ -0,0 +1,5 @@ +{ + "file": "packages/spec/src/stack.zod.ts", + "adrs": ["ADR-0015", "ADR-0143"], + "invariant": "ADR-0015 amendment D7: a datasource's `external.connector` borrows the credential of a connector instance its own stack declares — a `connectors` entry with a `provider` and an ADR-0143 instance `auth` other than `none`. `collectDatasourceConnectorErrors` refuses an undeclared name, a provider-less catalog descriptor or an auth-less instance at build, so an API-backed datasource never reaches its first read with no credential to present." +} From 400e1b097e159708d7f0ecf468072edf74d15e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:52:46 +0000 Subject: [PATCH 7/7] chore(spec): regenerate the datasource reference Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude --- content/docs/references/data/datasource.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index e570e01699f..b33bdf87ee1 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -75,7 +75,7 @@ const result = DatasourceSchema.parse(data); | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | | **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | -| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved the way the connector resolves it. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | +| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — whichever kind the instance's `auth` declares (`bearer`, `api-key` or `basic`; ADR-0143 D2 adds the grant-bearing kinds), resolved the way the connector resolves it. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | @@ -117,7 +117,7 @@ External datasource settings: federation policy (schemaMode != "managed") plus t | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | | **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | -| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — static, `oauth2`, `app-credential` and `signed` kinds alike, resolved the way the connector resolves it. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | +| **connector** | `string` | optional | Name of a declared connector instance (a `connectors` entry with a `provider`) whose credential this API-backed datasource borrows — whichever kind the instance's `auth` declares (`bearer`, `api-key` or `basic`; ADR-0143 D2 adds the grant-bearing kinds), resolved the way the connector resolves it. One Connect serves both the connector's actions and this datasource's records. Exclusive with `credentialsRef`; refused on a built-in database driver and on a managed datasource. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | ### Nested Shape: `ExternalDatasourceSettings.validation`