Skip to content

feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) - #27819

Draft
objectstack-fleet[bot] wants to merge 22 commits into
mainfrom
claude/project-thread-wu18sj
Draft

objectstack-fleet[bot] wants to merge 22 commits into
mainfrom
claude/project-thread-wu18sj

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

epic:#27261#1 · marchtian · session_017ihL2bt3wjGsdgJrX877gc

Requested by 田騏 · project thread

Fable review owed. Developed on Opus while the Fable quota is out. Clause-②: yes, so this is a contract face: it lands only after a full Fable review and a CONTRACT_REVIEW_TIER contract review record PASS on the current head.

Fixes #27746

Clause-②: yes
Metadata-protocol: none

维护者速读

  • 改了什么:数据源的 external.connector(feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783 的 spec 键)在运行时生效。自动化服务注册一个 connector-credential-source 服务,把已声明连接器实例的凭据(bearer、API key、basic)按连接器自己的解析链(先环境变量,再 Setup 里 Connector credentials 存的行,最后宿主解析器)借给数据源;数据源服务在连接、测试连接、管理端保存时借用,并把凭据句柄交给驱动工厂;驱动每次请求通过 @objectstack/types 的 callWithBorrowedCredential 出示:过期先刷新,401 刷新一次重试,再 401 则 503 并点名实例。OData 驱动(@objectstack/driver-odata)是第一个使用方:写了 external.connector 的数据源在首个请求时借到句柄,按实例 auth 的形式(bearer、basic、API key 请求头或查询参数)出示。实例未声明、只是目录描述、没有 auth、被禁用,或栈里没有自动化服务时,连接 fail-closed 拒绝并给出改法,不会连上再在第一次读时吃 401。
  • 为什么改:D7 裁决(6486136301)的运行时一半;没有它,spec 的键只是声明。
  • 风险与回滚:只对写了 external.connector 的数据源生效;credentialsRef 数据源不受影响。不动 objectql,不改 packages/spec/src/**(feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783 已落地,本分支已合回 main,diff 只剩本卡的文件)。回滚即 revert 本 PR。
  • 先看:packages/types/src/connector-credential-source.ts(契约与出示规则);packages/services/service-automation/src/connector-credential-source.ts(出借方);packages/services/service-datasource/src/datasource-connection-service.ts 的借用块;packages/drivers/driver-odata/src/transport.ts 的 request。
  • 两分钟验证:pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-connector-credential.test.ts,预期 14 个用例全绿,能看到静态凭据到达上游、oauth2 形状的凭据过期后刷新、401 刷新一次后重试、未声明与被禁用的实例在连接时被拒。
  • 需要你做的:Fable 复审与契约复审通过后落地本 PR(feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783 已于 01:37Z 落地)。

What changes

The dependency direction the ruling left open. The contract lives in @objectstack/types (packages/types/src/connector-credential-source.ts): IConnectorCredentialSource, ConnectorCredentialHandle, BorrowedConnectorCredential, the service name CONNECTOR_CREDENTIAL_SOURCE_SERVICE ('connector-credential-source') and callWithBorrowedCredential. service-automation (the lender) and service-datasource (the borrower) both already depend on that package, and so can an out-of-repository plugin driver, so no edge is added among objectql, service-datasource and service-automation. objectql is not touched: the engine sees a registered driver, never a credential.

The handle is per dispatch from the first kind. get({ organizationId }) answers the credential to send; refresh() answers a new one, single-flight per handle. callWithBorrowedCredential(handle, call, opts) is the one rule a driver presents through (ADR-0143 D4): refresh when expiresAt minus a 30 s margin has passed; on a 401 (a result with status: 401, a caller-supplied predicate, or a throw declaring status/statusCode 401) refresh once and retry; a second 401 throws 503 SERVICE_UNAVAILABLE naming the instance and the remedy, never the upstream's text (which may echo the credential). Under the plugin-driver floor that 503 is ERR_DATASOURCE_UNAVAILABLE with the message in the server log only.

The lender (packages/services/service-automation/src/connector-credential-source.ts, registered in init() and added to providesServices):

  • borrow(connector) judges the declaration the reconcile reads (readDeclaredConnectorItems, registry plus served view plus reload) and resolves no value. It refuses 422 VALIDATION_ERROR for an undeclared instance, a catalog descriptor (no provider), an instance with no auth or auth: { type: 'none' }, and a disabled one (enabled: false, the case feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783's contract review asked to see refused visibly rather than as a silent 401); 503 when the declaration cannot be read.
  • A handle resolves the instance's auth.credentialRef through the connector's own chain (resolveConnectorCredential: env, then the sys_connector_credential row, then the host's resolver; no stored arm under a walled posture), reading only that reference's row. A credential not set yet makes get refuse 503 with missingCredentialReason's remedy and answer once it is set, with no new borrow.
  • Today's instances carry static kinds, which ADR-0143 D4 leaves resolved once, so a handle holds its value until refresh() (the driver's 401 path picks up a value rotated where the plugin cannot see it, such as an env var), or until the plugin's credentialGeneration moves. It moves on every reconcile, and every credential door write runs one, so a value set through the door reaches the datasource's next request.
  • A host resolver's thrown message is never interpolated.

The borrower (packages/services/service-datasource):

  • datasource-connector-credential.ts holds the one borrow every door makes (borrowConnectorCredential, never throws; borrowedConnectorOf).
  • DatasourceConnectionService.attemptConnect borrows right after the credentialsRef block and with the same fail-closed posture: a refused borrow, or no source, is failed-credentials through handleFailure (so a datasource that objects bind to fails the boot, and the engine is marked unavailable), nothing is built and nothing registered. The handle goes to the factory as DatasourceConnectionSpec.credential.
  • DatasourceAdminServicePlugin resolves the source per use (safeGetService), passes it to the connection service, probes a draft with its borrowed handle, and answers connectorRefusal for the admin service.
  • DatasourceAdminService refuses createDatasource (before anything is written) and an updateDatasource that touches external, and answers testConnection with the reason without probing, when the named instance cannot lend. This is the "runtime-created datasource never passes defineStack" half feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783's acceptance notes left to this card.

The first presenter (packages/drivers/driver-odata, wired now that #27796 has landed):

  • plugin.ts gains a lazy borrowed() beside the credentialsRef provider. When the datasource definition names external.connector, it borrows a handle from the connector-credential-source service at the first request (kept per connector name; a refused borrow is not kept, so the next request asks again). A lend refusal is rethrown without its 422, so the engine's floor answers it as the connection's condition (503), not as a malformed request.
  • transport.ts sends every request through callWithBorrowedCredential when a handle exists: refreshed at expiry, once on a 401 (its body cancelled, never read), then the helper's 503. The instance's auth decides the presentation: Authorization: Bearer, Authorization: Basic, or an API key as its declared header or, with paramName, a query parameter. With no connector the credentialsRef path runs exactly as before, including its resolve-again-on-401.
  • Fail-closed: no source registered, both connector and credentialsRef on the definition, or an auth.scheme other than none on the plugin beside the connector (it would send a second credential) refuse the request with nothing sent.
  • README.md gains a "Borrowing a connector instance's credential" section; @objectstack/types joins the package's dependencies (pnpm-lock.yaml +3 lines).

Ledger. packages/spec/liveness/datasource.json's external.connector row flips planned to live with the two anchors (claim amendment 2), and state-counts/datasource.md is regenerated.

Changeset: patch on @objectstack/types, @objectstack/service-automation, @objectstack/service-datasource and @objectstack/driver-odata (Metadata-protocol: none takes patch).

Why

ADR-0015 amendment D7 (ruling 6486136301 on #27298): one Connect per vendor serves the connector's flow actions and the datasource's virtual objects. The spec half (#27742, #27783) declared and validated the key; until a runtime reads it the key does nothing. Both should ship in the same release.

Verification

Re-run on fb1e8502 (main merged back in after #27783 landed) is in progress; this section is updated with its results. The figures below are from e3d68242, before driver-odata was wired, plus the driver-odata lines.

  • driver-odata on e496ecf5: typecheck exit 0; pnpm --filter @objectstack/driver-odata test 6 files, 450 tests passed; src/plugin.test.ts 20 (11 new for the borrowed case: the static credential sent and the secret store never asked; basic, API key header and API key query parameter; refresh once on a 401 then retry; refresh at expiry; a second 401 as a 503 naming the instance and not echoing the upstream; five fail-closed refusals with nothing sent; a lend refusal without its 422, borrowed again next request). Four ablations each turn the new block red: the borrowed path skipped (6 failed), the lend refusal keeping its 422 (1), a refused borrow cached (1), a plugin auth.scheme allowed beside the connector (1).

On e3d68242 (origin/main and #27783's 400e1b09 merged in), after pnpm install --frozen-lockfile && pnpm build (114/114 tasks):

  • pnpm --filter @objectstack/types test: 31 files, 810 tests passed. pnpm --filter @objectstack/service-datasource test: 64 files, 1040 passed. pnpm --filter @objectstack/service-automation test: 219 files, 2810 passed. (Taken before the last merge of main; see the gates line for the re-run.)
  • typecheck exit 0 for all three packages; eslint over the eleven touched source and test files exit 0.
  • New tests: packages/types/src/connector-credential-source.test.ts (12: static pass-through, refresh on expiry and inside the skew margin, no refresh while valid, refresh-once on a 401 result and on a 401 throw, the 503 without upstream text, non-401 pass-through, a custom predicate, the organization forwarded, single-flight under concurrency, a handle refusal propagated); packages/services/service-automation/src/connector-credential-source.test.ts (15, real ObjectQL over SQLite for the stored arm: registration and providesServices, bearer from env, api-key and basic shapes, unset then set then rewritten with no refresh, an env rotation picked up through the 401 path, five 422 refusals, and the handle's 503s); packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts (14, with a lender double and an API driver double presenting through the helper: static credential reaches the upstream, oauth2-shaped refresh after expiry, refresh once on 401 then 503, undeclared and disabled instances and a missing lender refused at connect with nothing built, the boot failing for a bound datasource, the admin door refusing create, update and probe).
Ablations (each pin can go red)

Through scripts/ablation-replace.mjs (anchor hit, blob moved, restored, git diff HEAD empty afterwards):

  • datasource-connection-service.ts: the borrow guard made false (blob 3530b62bb166 to 401f123de28c): the connect block goes red, 7 failed.
  • datasource-admin-service.ts: connectorLendRefusal answers nothing (420e0221f6d0 to bcdabd366b06): the admin block goes red, 4 failed.
  • service-automation/src/connector-credential-source.ts: the disabled refusal never matches (16a766b32331 to ddaf1e5fb677): 1 failed, the disabled case.
  • service-automation/src/plugin.ts: the reconcile no longer moves credentialGeneration (8744db630ecd to 6e3897f93f09): 1 failed, the write-picked-up-without-refresh case.
  • types/src/connector-credential-source.ts: no refresh before the retry (46ece16a317c to 922e1bb8b425): 6 failed.

Local gates on the merged head (after the rebuild): node scripts/pm/dispatch-gates.mjs --commands derived 122 families from the 26 changed files (#27783's included); every one was run with its exit code recorded before any pipe, all 122 exit 0 on the first pass, and dispatch-gates --ran reads "122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). pnpm --filter @objectstack/spec check:liveness re-run green after the liveness note's last edit. The full battery (pnpm lint, Test Core shards, UI jobs) is CI's. No dogfood run: no ! in the title and no (narrowing) arm.

Acceptance notes

Human Minimum

🤖 Generated with Claude Code


Generated by Claude Code

claude added 17 commits October 29, 2026 22:16
…r, a connector name

Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG
Co-authored-by: Claude <noreply@anthropic.com>
…ial to a datasource (ADR-0015 D7)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
…connect, probe and save (ADR-0015 D7)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
…ve (ADR-0015 D7)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
…kinds that exist and how each resolves; anchor D7

Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG
Co-authored-by: Claude <noreply@anthropic.com>
…asource failed, naming the instance (ADR-0015 D7)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
…esolve once and are held, per dispatch only for grant-bearing kinds

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/driver-odata, @objectstack/service-automation, @objectstack/service-datasource, @objectstack/spec, @objectstack/types, touching 56 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/drivers/driver-odata/README.md, packages/drivers/driver-odata/package.json, packages/spec/liveness/datasource.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ddab8b996152d39f30d29915af8e6ec63398c5f2.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/drivers/driver-odata/README.md, packages/drivers/driver-odata/package.json, packages/spec/liveness/datasource.json, …) — pages documenting those are invisible to this run
  • 21 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 169 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ddab8b996152d39f30d29915af8e6ec63398c5f2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e75cf9b6a97c8f26ebc40d4e1ff7f536a6484888 — the merge of head fb1e8502e0e07a795ef713bf7c230d36f4034d92 into base ddab8b996152d39f30d29915af8e6ec63398c5f2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e75cf9b6a97c8f26ebc40d4e1ff7f536a6484888 && git checkout e75cf9b6a97c8f26ebc40d4e1ff7f536a6484888
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ddab8b996152d39f30d29915af8e6ec63398c5f2 fb1e8502e0e07a795ef713bf7c230d36f4034d92 && git checkout -B drift-repro ddab8b996152d39f30d29915af8e6ec63398c5f2 && git merge --no-ff fb1e8502e0e07a795ef713bf7c230d36f4034d92

node scripts/docs-audit/affected-docs.mjs --json ddab8b996152d39f30d29915af8e6ec63398c5f2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ddab8b996152d39f30d29915af8e6ec63398c5f2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… the connector instance's borrowed credential

The OData plugin borrows a handle from the connector-credential-source service at
the first request when its datasource names external.connector, and sends every
request through callWithBorrowedCredential: refreshed at expiry, once on a 401,
then a 503 naming the instance. The instance's auth decides the presentation
(bearer, basic, an API key header or query parameter). credentialsRef keeps its
path; both channels, a plugin auth scheme beside the connector, no source, or a
lend refusal refuse the request, sending nothing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
…-wu18sj

# Conflicts:
#	packages/spec/liveness/datasource.json

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

2 participants