Skip to content

Commit b373263

Browse files
feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) (#27783)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi3f3f6RH93TSgeMT1ET96P6)_ **Fable review owed.** Developed on Opus while the Fable quota is out. This PR touches the published spec (`packages/spec/src/**`) and an ADR, so it lands only after a full Fable review, a `CONTRACT_REVIEW_TIER` contract review record PASS on the current head, and 田騏's approval (Tier H). Fixes #27742 Clause-②: yes Metadata-protocol: added Skills-docs: follow-up owed — skills/objectstack-data/rules/datasources.md, content/docs/data-modeling/virtual-objects.mdx, content/docs/data-modeling/external-datasources.mdx ## 维护者速读 - **改了什么**:数据源的 `external` 块新增一个可选键 `connector`,写一个已声明的连接器实例名,数据源借用该实例的授权(静态、oauth2、app-credential、signed 皆可)。写错时在构建时响亮拒收:实例不存在、该连接器没有 `provider`(只是目录描述)、实例没有 `auth`;与 `credentialsRef` 同写、用在内置数据库驱动(postgres/mysql/sqlite/mongodb 等)上、或用在 `managed` 数据源上,解析时即拒收并给出改法。ADR-0015 加 D7 修订注。 - **为什么改**:D7 裁决(6486136301,字母 A):SAP、Dynamics 365 这类 OAuth2 供应商,一次 Connect 让流程动作和虚拟对象读数共用,重连一次两边都恢复。 - **风险与回滚**:只加键、不改已有键含义,`credentialsRef` 照旧;运行时尚未读取该键(运行时半边是 #27746,被本卡阻塞),在它落地前该键只校验不生效,两者应同版发布。回滚即 revert 本 PR。 - **先看**:`packages/spec/src/data/datasource.zod.ts`(新键与三条拒收);`packages/spec/src/stack.zod.ts` 的 `collectDatasourceConnectorErrors`(构建时实例核对);`docs/adr/0015-external-datasource-federation.md` 顶部 D7 注。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/stack-datasource-connector.test.ts src/data/datasource.test.ts`,预期两个文件全绿;用例里能看到四种构建时拒收与三种解析时拒收的原文。 - **需要你做的**:Fable 复审与契约复审通过后,批准本 PR(Tier H)。 ## What changes **The key.** `ExternalDatasourceSettingsSchema` gains `connector` (`packages/spec/src/data/datasource.zod.ts`): an optional snake_case name of a declared connector instance whose credential an API-backed datasource borrows (ADR-0015 amendment D7, ruling 6486136301 on #27298). Near-miss spellings (`connectorName`, `connectorId`, `connectorRef`, `connectorInstance`) are refused with the "did you mean `connector`" prescription, and a `connector` written one level too high, on the datasource itself, is pointed into the `external` block. **Refused at parse** (`DatasourceSchema`'s refinement, issue path `external.connector`, each message naming the fix): - beside a bound `credentialsRef`: the two channels are exclusive. The card allowed "mutually exclusive, or the precedence named and pinned"; exclusivity was chosen because a precedence rule leaves 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 is not the second channel. - on a built-in database driver (every id in `DRIVER_VOCABULARY`, via `resolveDriverId`): a connector lends an HTTP credential; the message prescribes `credentialsRef`. - on a managed datasource with a plugin driver: the message prescribes `schemaMode: 'external'` (or `'validate-only'`), not the generic "remove these keys" of the managed-block refusal, which no longer lists `connector`. **Refused at build** (`collectDatasourceConnectorErrors` in `packages/spec/src/stack.zod.ts`, run by `validateCrossReferences`, so `defineStack` and `os validate` both refuse with `STACK_CROSS_REFERENCE_INVALID` / 422), judged against the stack's own `connectors`, like a job's mapping reference: - no `connectors` entry of that name; - an entry with no `provider` (an ADR-0097 catalog descriptor, never materialized); - an instance with no `auth`, or `auth: { type: 'none' }` (nothing to lend). A disabled instance (`enabled: false`) is accepted: the switch turning the vendor off for its actions and its records alike is the one-grant behaviour D7 asks for. **ADR-0015.** An amendment note D7 at the top and a pointer in §10, recording the second credential channel, the three exclusions, that execution identity is not reopened (ADR-0143 Q1 = A; object permissions and RLS stay the only gate), and the answer to the ruling's open reading below. **Ledgers and generated files.** `packages/spec/liveness/datasource.json` gets a `planned` row for `external.connector` (evidence: the two refusal sites; flips to `live` with the runtime card), and the credential-name ratchet in `metadata-credential-fields.test.ts` allow-lists `datasource:external.connector` with its reason: its describe says "credential", but the field holds a connector name and the credential stays on the instance's own `auth.credentialRef`. `content/docs/references/data/datasource.mdx`, `packages/spec/authorable-surface/data.json` and `packages/spec/liveness/state-counts/datasource.md` are regenerated. Changeset: `minor` on `@objectstack/spec`. ## The ruling's open reading: does the `(organization, connector instance)` binding fit an environment-level datasource? Yes, under the posture #27230 S1 ruled. A datasource stays environment-scoped (ADR-0015 §10). The connection it borrows is selected per dispatch by the request's organization (ADR-0143 Q1 = A, D4), so a virtual-object read made in an organization uses that organization's grant, exactly as a flow dispatch does. A read that carries no organization (the boot-time drift check, a schedule) falls under D4's rule: it uses the instance's connection only when exactly one exists, and is refused otherwise, never answered from an arbitrary organization's grant. S1 ruled one organization per environment for connector credentials ("暂时不考虑 一个环境里住多个组织的情况"), so there is always exactly one connection, and the environment-level datasource and the organization-bound connection record meet with no new rule. A shared environment hosting several organizations is outside S1; there the no-organization read is refused by D4, which is fail-closed. Recorded in the ADR-0015 D7 note. The ruling's other open reading (whether the `objectql`-side driver reaches the automation service's resolver without a new dependency direction) is the runtime half's question, carried by #27746; nothing in this diff decides it. ## Tests On `eda26e3c`, each under `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/spec test`: Test Files 756 passed (756), Tests 22468 passed | 1 todo (22469); command-exit 0 (4m15s). - `pnpm --filter @objectstack/spec typecheck`: command-exit 0 (1m22s). - New: `packages/spec/src/stack-datasource-connector.test.ts` (8 cases: the four build-time refusals with the `STACK_CROSS_REFERENCE_INVALID` / 422 envelope, one finding per borrowing datasource in a stack with objects, and three controls) and the `external.connector` block in `packages/spec/src/data/datasource.test.ts` (11 cases: acceptance in `external` and `validate-only`, the declared key, the four alias prescriptions, the name pattern, the three parse refusals with their paths and fixes, the empty-`credentialsRef` control, the top-level guidance, and a `credentialsRef`-alone control). <details><summary>Ablations (both refusal halves can go red)</summary> Mutations through `scripts/ablation-replace.mjs` (anchor hit 1, blob moved, restored by the tool, `git diff HEAD` empty afterwards): - `stack.zod.ts`: `errors.push(...collectDatasourceConnectorErrors(config));` replaced by `void collectDatasourceConnectorErrors;` (blob `c8cc7c644281` to `e0bfdb0f2204`): `stack-datasource-connector.test.ts` 5 failed, 3 passed (the five refusal cases red, the three controls green). - `datasource.zod.ts`: the `if (ds.external?.connector !== undefined) {` guard made false (blob `e0f177402a59` to `1090eb4caaf4`): the `external.connector` block 4 failed, 7 passed (the four parse-refusal cases red). </details> ## Acceptance notes - **Declared ahead of its reader.** No runtime reads `external.connector` yet: the datasource connection service handing the borrowed credential to the driver is #27746, blocked by this card. Until it lands, the only shape the spec accepts is a plugin-contributed driver's datasource naming a valid instance, and no such driver ships in this repository today (the OData driver is #27741). The liveness row says `planned`, and the changeset says the key is validated and not yet read. #27746 should land in the same release. - **A runtime-created datasource** (Studio, `origin: 'runtime'`) is parsed by `DatasourceSchema` but never passes through `defineStack`, so the "instance is declared" half does not judge it here. That check belongs at the runtime half's boot and save seams (#27746). - **Cross-package borrowing** (a datasource in one package naming a connector another package declares) is refused per stack, the same scope a job's mapping reference uses. No current producer does this; widening to the composed artifact is a separate change if one appears. - **No ADR anchor file** was added (`scripts/adr-anchors/`): the claim's file surface did not list it, and both refusal sites carry the ADR-0015 D7 reference in their own comments. - **Epic body.** The ruling's amendment of #27261 §4 item 8 ("through `credentialsRef`, or through a declared connector instance for an API-backed datasource") is the epic PM's edit, not this diff's. - **Skills and guide.** `Skills-docs:` above names the follow-up: the data skill and the virtual-objects guide still teach `credentialsRef` as the only channel. Teaching the new key before its runtime lands would advertise a capability the runtime does not deliver, so it rides with or after #27746. ## Verification Local gates on `eda26e3c`, exit codes captured to a file before any pipe. `node scripts/pm/dispatch-gates.mjs --commands` derived 111 families from the 11 changed files; every one was run. - 105 exit 0 on the first pass, among them spec `check:generated`, `check:docs`, `check:api-surface`, `check:authorable-surface`, `check:liveness`, `check:strictness-ledger`, `check:export-origins`, `check:llms-txt`, `check:spec-parsed-alias`, `check:spec-docblock-symbol-anchors`, `check:stack-collection-maps`, `check:doc-authoring`, `check:adr-links`, `check:adr-anchors`, `check:adr-index`, `check:issue-citations`, `check:nul-bytes`, `check:merge-driver`, `check:pm-prior-rulings`, `check:type-check-debt`, `check-adr-0087-registration --base origin/main`, `check-empty-changeset --base origin/main`, `check-changeset-no-major --base origin/main` and the `--self-test` batteries. - 6 exit 3 (prerequisite not met) on the first pass, each re-run green after its prerequisite: `check-plugin-teardown-shape --self-test` (fixture commit fetched), lint `check:doc-formula-expressions` and `check:doc-security-posture` (`@objectstack/lint` built), spec `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` (the published packages' `dist/` built by `check:type-check-debt`). - `dispatch-gates --ran`: "111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). - `pnpm --filter @objectstack/spec check:generated` on the merged head after a build: 14 of 14 up to date (exit 0). - `origin/main` merged once before opening (`eda26e3c`); `packages/spec` had moved on main (analytics), so spec was rebuilt, `check:generated` re-run, and the full spec suite above was taken on the merged head. - The full battery (`pnpm lint`, Test Core shards, UI jobs) is CI's and was not re-run locally. No dogfood run: no `!` in the title and no `(narrowing)` arm. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a4b0fb commit b373263

13 files changed

Lines changed: 495 additions & 7 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an API-backed datasource names a declared connector instance in `external.connector` and borrows its credential
6+
7+
Clause-②: yes
8+
Metadata-protocol: added
9+
10+
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:
11+
12+
```ts
13+
connectors: [{ name: 'sap_s4', label: 'SAP S/4HANA', type: 'api', provider: 'openapi',
14+
providerConfig: { spec: './sap-openapi.json' }, auth: { type: 'bearer', credentialRef: 'sap_token' } }],
15+
datasources: [{ name: 'sap_orders', driver: 'odata', config: { serviceUrl: '…' },
16+
schemaMode: 'external', external: { connector: 'sap_s4' } }],
17+
```
18+
19+
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:
20+
21+
- beside a bound `external.credentialsRef` — the two credential channels are exclusive;
22+
- on a built-in database driver (`postgres`, `mysql`, `sqlite`, `mongodb`, …) — a connector lends an HTTP credential; bind the database secret with `credentialsRef`;
23+
- on a managed datasource — set `schemaMode: 'external'` (or `'validate-only'`);
24+
- 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.
25+
26+
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.

‎content/docs/references/data/datasource.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const result = DatasourceSchema.parse(data);
3838
| **active** | `boolean` | optional (default: `true`) | Is datasource enabled |
3939
| **autoConnect** | `boolean` | optional (default: `false`) | Force a live driver connection at boot even when managed + unrouted (ADR-0062 D2). |
4040
| **schemaMode** | `Enum<'managed' \| 'external' \| 'validate-only'>` | optional (default: `"managed"`) | Schema ownership mode |
41-
| **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) |
41+
| **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 |
4242
| **origin** | `Enum<'code' \| 'runtime'>` | optional (default: `"code"`) | Datasource provenance (server-managed, read-only) |
4343
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
4444
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
@@ -75,6 +75,7 @@ const result = DatasourceSchema.parse(data);
7575
| **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. |
7676
| **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy |
7777
| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. |
78+
| **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. |
7879
| **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. |
7980

8081

@@ -106,7 +107,7 @@ Underlying driver identifier
106107

107108
## ExternalDatasourceSettings
108109

109-
External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode)
110+
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
110111

111112
### Properties
112113

@@ -116,6 +117,7 @@ External datasource settings: federation policy (schemaMode != "managed") plus t
116117
| **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. |
117118
| **validation** | `{ onMismatch?: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot?: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy |
118119
| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. |
120+
| **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. |
119121
| **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. |
120122

121123
### Nested Shape: `ExternalDatasourceSettings.validation`

‎docs/adr/0015-external-datasource-federation.md‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,39 @@
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source of truth per metadata type)
66
**Consumers**: `@objectstack/spec`, `@objectstack/runtime`, `@objectstack/objectql`, `@objectstack/plugins/driver-sql`, `@objectstack/services/service-ai`, `@objectstack/cli`, `../objectui`
7-
**Amended by**: [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) (2026-10-27), attribution only — see the note below.
7+
**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.
8+
9+
> **Amendment note D7 (2026-10-29, ruling 6486136301, letter A) — a second credential channel.**
10+
> §10 says credentials reach a datasource only through `credentialsRef`. That stays true for a
11+
> database. An **API-backed** datasource (a plugin-contributed driver over a vendor API, such as
12+
> OData) may instead name a declared connector instance in `external.connector` and borrow that
13+
> instance's credential — whichever kind its `auth` declares (`ConnectorInstanceAuthSchema`:
14+
> `bearer`, `api-key` and `basic` today; ADR-0143 D2 adds the grant-bearing `oauth2`,
15+
> `app-credential` and `signed`), resolved as ADR-0143 D4 resolves the instance's own: a static kind
16+
> once at materialization, a grant-bearing kind per dispatch. The datasource connection service hands
17+
> it to the driver (the runtime half, #27746). One Connect per vendor then serves both the connector's
18+
> actions (flows) and the datasource's records (virtual objects), and one reconnect restores both.
19+
> The contract (`packages/spec/src/data/datasource.zod.ts#ExternalDatasourceSettingsSchema`):
20+
>
21+
> - `connector` and `credentialsRef` are exclusive; a datasource carrying both is refused, rather
22+
> than one silently winning.
23+
> - `connector` is refused on a built-in database driver (it lends an HTTP credential) and on a
24+
> managed datasource (an API-backed datasource's schema is the remote system's).
25+
> - The named instance must be declared in the same stack: a `connectors` entry with a
26+
> `provider` and an `auth` other than `none`. Anything else is refused at build
27+
> (`packages/spec/src/stack.zod.ts#collectDatasourceConnectorErrors`).
28+
>
29+
> Execution identity is not reopened: ADR-0143 Q1 = A (organization-shared) applies to the
30+
> virtual-object read as to the flow dispatch, and object permissions and RLS stay the only gate
31+
> on what a user sees. A datasource stays environment-scoped (§10). The connection it borrows is
32+
> selected per dispatch by the request's organization, and a read that carries no organization
33+
> (a boot-time drift check, a schedule) uses the instance's connection only when exactly one exists
34+
> (ADR-0143 D4). Under the ruled single-organization posture (#27230 S1: one environment, one
35+
> organization), that is always exactly one, so the environment-level datasource and the
36+
> `(organization, connector instance)` connection record meet without a new rule. A shared
37+
> environment hosting several organizations is outside S1: there a no-organization read is
38+
> refused, never answered from an arbitrary organization's grant. Not taken: a second auth block
39+
> on the datasource (two grants per vendor that drift), and a static key for OAuth sources.
840
941
> **Amendment note (2026-10-27, ADR-0143) — attribution only, no decision changes.** ADR-0018,
1042
> ADR-0022, ADR-0023, ADR-0024 and ADR-0097 put OAuth2 refresh and per-tenant connection lifecycle
@@ -706,7 +738,10 @@ managed Postgres for low-latency joins.
706738

707739
- **Credentials** never appear in metadata artefacts. `credentialsRef`
708740
points at `service-secrets`; the secrets service is the only
709-
component that resolves the actual connection string.
741+
component that resolves the actual connection string. *(Amended by
742+
D7, see the note at the top: an API-backed datasource may instead
743+
borrow a declared connector instance's credential through
744+
`external.connector`.)*
710745
- **Connection pools** default conservatively for external mode
711746
(`max: 5`) to protect the remote system; `pool` may be overridden.
712747
- **Timeouts** are enforced at the driver. AI tool calls additionally

‎packages/spec/authorable-surface/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@
271271
"data/ExternalColumn:suggestedFieldType",
272272
"data/ExternalDatasourceSettings:allowWrites",
273273
"data/ExternalDatasourceSettings:allowedSchemas",
274+
"data/ExternalDatasourceSettings:connector",
274275
"data/ExternalDatasourceSettings:credentialsRef",
275276
"data/ExternalDatasourceSettings:queryTimeoutMs",
276277
"data/ExternalDatasourceSettings:validation",

‎packages/spec/liveness/datasource.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@
144144
"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)",
145145
"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."
146146
},
147+
"connector": {
148+
"status": "planned",
149+
"verifiedAt": "2026-10-29",
150+
"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)",
151+
"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."
152+
},
147153
"queryTimeoutMs": {
148154
"status": "live",
149155
"verifiedAt": "2026-08-28",

‎packages/spec/liveness/state-counts/datasource.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `datasource` | 30 | 0 | 0 | 0 | 0 | 30 |
15+
| `datasource` | 30 | 0 | 0 | 0 | 1 | 31 |

‎packages/spec/src/data/datasource.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,3 +848,112 @@ describe('datasource `password` guidance — following it parses', () => {
848848
expect(result.success).toBe(true);
849849
});
850850
});
851+
852+
// ── ADR-0015 amendment D7 — `external.connector` ──────────────────────────────
853+
//
854+
// An API-backed datasource names a declared connector instance and borrows its
855+
// credential (ruling 6486136301). The datasource-level half refuses the three
856+
// pairings the key can never serve; whether the instance is declared is the
857+
// stack's half (`stack-datasource-connector.test.ts`).
858+
describe('external.connector — an API-backed datasource borrows a connector instance credential', () => {
859+
const odata = {
860+
name: 'sap_orders',
861+
driver: 'odata',
862+
config: { serviceUrl: 'https://sap.example.com/odata/v4/orders' },
863+
schemaMode: 'external',
864+
} as const;
865+
866+
const issuesAt = (raw: unknown, path: string) => {
867+
const result = DatasourceSchema.safeParse(raw);
868+
return result.success ? [] : result.error.issues.filter((i) => i.path.join('.') === path);
869+
};
870+
871+
it('accepts the key on a plugin driver in external and validate-only modes', () => {
872+
for (const schemaMode of ['external', 'validate-only'] as const) {
873+
const result = DatasourceSchema.safeParse({ ...odata, schemaMode, external: { connector: 'sap_s4' } });
874+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
875+
if (result.success) expect(result.data.external?.connector).toBe('sap_s4');
876+
}
877+
});
878+
879+
it('is a declared key, not an unrecognized one', () => {
880+
const result = ExternalDatasourceSettingsSchema.safeParse({ connector: 'sap_s4' });
881+
expect(result.success).toBe(true);
882+
});
883+
884+
it('folds the near-miss spellings onto `connector`', () => {
885+
for (const spelling of ['connectorName', 'connectorId', 'connectorRef', 'connectorInstance']) {
886+
const result = DatasourceSchema.safeParse({ ...odata, external: { [spelling]: 'sap_s4' } });
887+
const messages = result.success ? [] : result.error.issues.map((i) => i.message);
888+
expect(messages.join('\n'), spelling).toContain(`\`${spelling}\` → \`connector\``);
889+
}
890+
});
891+
892+
it('takes a connector name, so it refuses a name that is not snake_case', () => {
893+
expect(issuesAt({ ...odata, external: { connector: 'SAP-S4' } }, 'external.connector')).toHaveLength(1);
894+
});
895+
896+
it('refuses `connector` beside a bound `credentialsRef` — the two channels are exclusive', () => {
897+
const hits = issuesAt(
898+
{ ...odata, external: { connector: 'sap_s4', credentialsRef: 'sys_secret:9f2c' } },
899+
'external.connector',
900+
);
901+
expect(hits).toHaveLength(1);
902+
expect(hits[0]!.code).toBe('custom');
903+
expect(hits[0]!.message).toContain('two credential channels');
904+
});
905+
906+
it('an empty-string `credentialsRef` is not a binding, so it is not the second channel', () => {
907+
const result = DatasourceSchema.safeParse({ ...odata, external: { connector: 'sap_s4', credentialsRef: '' } });
908+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
909+
});
910+
911+
it('refuses `connector` on every built-in database driver, naming the driver', () => {
912+
const builtins = [
913+
{ driver: 'postgres', config: { host: 'db.internal', database: 'analytics', username: 'ro' } },
914+
{ driver: 'mysql', config: { host: 'db.internal', database: 'analytics', username: 'ro' } },
915+
{ driver: 'sqlite', config: { filename: 'warehouse.db' } },
916+
];
917+
for (const { driver, config } of builtins) {
918+
const hits = issuesAt(
919+
{ name: 'warehouse', driver, config, schemaMode: 'external', external: { connector: 'sap_s4' } },
920+
'external.connector',
921+
);
922+
expect(hits, driver).toHaveLength(1);
923+
expect(hits[0]!.message).toContain(`'${driver}' driver connects to a database`);
924+
expect(hits[0]!.message).toContain('external.credentialsRef');
925+
}
926+
});
927+
928+
it('refuses `connector` on a managed datasource, prescribing the schema mode — not the generic managed-block deletion', () => {
929+
const result = DatasourceSchema.safeParse({ ...odata, schemaMode: 'managed', external: { connector: 'sap_s4' } });
930+
expect(result.success).toBe(false);
931+
const issues = result.success ? [] : result.error.issues;
932+
const hits = issues.filter((i) => i.path.join('.') === 'external.connector');
933+
expect(hits).toHaveLength(1);
934+
expect(hits[0]!.message).toContain("set `schemaMode: 'external'`");
935+
// The generic managed-block refusal does not also list it for deletion.
936+
expect(issues.some((i) => i.path.join('.') === 'external')).toBe(false);
937+
});
938+
939+
it('a managed built-in datasource gets the driver refusal alone — the mode advice would not fix it', () => {
940+
const hits = issuesAt(
941+
{ name: 'analytics', driver: 'postgres', config: { host: 'db.internal', database: 'a', username: 'ro' }, external: { connector: 'sap_s4' } },
942+
'external.connector',
943+
);
944+
expect(hits).toHaveLength(1);
945+
expect(hits[0]!.message).toContain("'postgres' driver connects to a database");
946+
});
947+
948+
it('a `connector` written at the datasource level is pointed into the `external` block', () => {
949+
const result = DatasourceSchema.safeParse({ ...odata, external: {}, connector: 'sap_s4' });
950+
expect(result.success).toBe(false);
951+
const messages = result.success ? [] : result.error.issues.map((i) => i.message);
952+
expect(messages.join('\n')).toContain('it belongs in the `external` block');
953+
});
954+
955+
it('CONTROL: `credentialsRef` alone on a plugin driver still parses, unchanged', () => {
956+
const result = DatasourceSchema.safeParse({ ...odata, external: { credentialsRef: 'sys_secret:9f2c' } });
957+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
958+
});
959+
});

0 commit comments

Comments
 (0)