Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/27742-datasource-external-connector.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions content/docs/references/data/datasource.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 — 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. |


Expand Down Expand Up @@ -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

Expand All @@ -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 — 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`
Expand Down
39 changes: 37 additions & 2 deletions docs/adr/0015-external-datasource-federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,39 @@
**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 — 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`):
>
> - `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
Expand Down Expand Up @@ -706,7 +738,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
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/liveness/datasource.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/datasource.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
109 changes: 109 additions & 0 deletions packages/spec/src/data/datasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`\`${spelling}\` → \`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);
});
});
Loading
Loading