Repository navigation
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
Conversation
Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude <noreply@anthropic.com>
…r, a connector name Claude-Session: https://claude.ai/code/session_017ihL2eRfGhMmx9N4rdwNtG Co-authored-by: Claude <noreply@anthropic.com>
…to claude/project-thread-wu18sj
…r (ADR-0015 D7) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
…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>
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
…to claude/project-thread-wu18sj
…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
📓 Docs Drift CheckThis PR changes 5 package(s): 36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 169 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…pool is restored only when one is live Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
… 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
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 aCONTRACT_REVIEW_TIERcontract 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。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 刷新一次后重试、未声明与被禁用的实例在连接时被拒。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 nameCONNECTOR_CREDENTIAL_SOURCE_SERVICE('connector-credential-source') andcallWithBorrowedCredential.service-automation(the lender) andservice-datasource(the borrower) both already depend on that package, and so can an out-of-repository plugin driver, so no edge is added amongobjectql,service-datasourceandservice-automation.objectqlis 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 whenexpiresAtminus a 30 s margin has passed; on a401(a result withstatus: 401, a caller-supplied predicate, or a throw declaringstatus/statusCode401) refresh once and retry; a second401throws503 SERVICE_UNAVAILABLEnaming the instance and the remedy, never the upstream's text (which may echo the credential). Under the plugin-driver floor that 503 isERR_DATASOURCE_UNAVAILABLEwith the message in the server log only.The lender (
packages/services/service-automation/src/connector-credential-source.ts, registered ininit()and added toprovidesServices):borrow(connector)judges the declaration the reconcile reads (readDeclaredConnectorItems, registry plus served view plus reload) and resolves no value. It refuses422 VALIDATION_ERRORfor an undeclared instance, a catalog descriptor (noprovider), an instance with noauthorauth: { 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);503when the declaration cannot be read.auth.credentialRefthrough the connector's own chain (resolveConnectorCredential: env, then thesys_connector_credentialrow, then the host's resolver; no stored arm under a walled posture), reading only that reference's row. A credential not set yet makesgetrefuse503withmissingCredentialReason's remedy and answer once it is set, with no new borrow.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'scredentialGenerationmoves. 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.The borrower (
packages/services/service-datasource):datasource-connector-credential.tsholds the one borrow every door makes (borrowConnectorCredential, never throws;borrowedConnectorOf).DatasourceConnectionService.attemptConnectborrows right after thecredentialsRefblock and with the same fail-closed posture: a refused borrow, or no source, isfailed-credentialsthroughhandleFailure(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 asDatasourceConnectionSpec.credential.DatasourceAdminServicePluginresolves the source per use (safeGetService), passes it to the connection service, probes a draft with its borrowed handle, and answersconnectorRefusalfor the admin service.DatasourceAdminServicerefusescreateDatasource(before anything is written) and anupdateDatasourcethat touchesexternal, and answerstestConnectionwith the reason without probing, when the named instance cannot lend. This is the "runtime-created datasource never passesdefineStack" 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.tsgains a lazyborrowed()beside thecredentialsRefprovider. When the datasource definition namesexternal.connector, it borrows a handle from theconnector-credential-sourceservice 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 its422, so the engine's floor answers it as the connection's condition (503), not as a malformed request.transport.tssends every request throughcallWithBorrowedCredentialwhen a handle exists: refreshed at expiry, once on a401(its body cancelled, never read), then the helper's503. The instance'sauthdecides the presentation:Authorization: Bearer,Authorization: Basic, or an API key as its declared header or, withparamName, a query parameter. With no connector thecredentialsRefpath runs exactly as before, including its resolve-again-on-401.connectorandcredentialsRefon the definition, or anauth.schemeother thannoneon the plugin beside the connector (it would send a second credential) refuse the request with nothing sent.README.mdgains a "Borrowing a connector instance's credential" section;@objectstack/typesjoins the package's dependencies (pnpm-lock.yaml+3 lines).Ledger.
packages/spec/liveness/datasource.json'sexternal.connectorrow flipsplannedtolivewith the two anchors (claim amendment 2), andstate-counts/datasource.mdis regenerated.Changeset:
patchon@objectstack/types,@objectstack/service-automation,@objectstack/service-datasourceand@objectstack/driver-odata(Metadata-protocol: nonetakes 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 frome3d68242, beforedriver-odatawas wired, plus the driver-odata lines.driver-odataone496ecf5:typecheckexit 0;pnpm --filter @objectstack/driver-odata test6 files, 450 tests passed;src/plugin.test.ts20 (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 pluginauth.schemeallowed beside the connector (1).On
e3d68242(origin/mainand #27783's400e1b09merged in), afterpnpm 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 ofmain; see the gates line for the re-run.)typecheckexit 0 for all three packages;eslintover the eleven touched source and test files exit 0.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 andprovidesServices, 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 HEADempty afterwards):datasource-connection-service.ts: the borrow guard made false (blob3530b62bb166to401f123de28c): the connect block goes red, 7 failed.datasource-admin-service.ts:connectorLendRefusalanswers nothing (420e0221f6d0tobcdabd366b06): the admin block goes red, 4 failed.service-automation/src/connector-credential-source.ts: the disabled refusal never matches (16a766b32331toddaf1e5fb677): 1 failed, the disabled case.service-automation/src/plugin.ts: the reconcile no longer movescredentialGeneration(8744db630ecdto6e3897f93f09): 1 failed, the write-picked-up-without-refresh case.types/src/connector-credential-source.ts: no refresh before the retry (46ece16a317cto922e1bb8b425): 6 failed.Local gates on the merged head (after the rebuild):
node scripts/pm/dispatch-gates.mjs --commandsderived 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, anddispatch-gates --ranreads "122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).pnpm --filter @objectstack/spec check:livenessre-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
ConnectorInstanceAuthSchemais stillnone | bearer | api-key | basic, and ADR-0143 D4's per-dispatch resolver and connection record are connectors: outbound OAuth in the open tier — connection-scoped authorization-code / client-credentials / app-credential tokens, refreshed per dispatch, for the openapi, mcp and rest providers #26702 items 2 to 4. The handle has D4's shape so those kinds plug in without a driver change; the card's "oauth2 refreshed after expiry" test runs against a source double minting expiring tokens (claim amendment 6511046968). Organization selection (organizationId) is forwarded and ignored by the static kinds, which are environment-level.driver-odatais wired here. feat(driver-odata): a declarative OData v4 datasource driver, published as a plugin driver id behind the engine floor #27796 (driver-odata: a declarative OData v4 datasource driver (plugin-published id, pushdown for $filter/$orderby/$top/$skip/$count, the engine floor for the rest) — epic #27261 P2 #27741) landed first, so this PR, landing second, wires it tocallWithBorrowedCredential(claim 6511028605, amended forpackages/drivers/driver-odata/src/**).rejected. The connector path does that on a call refusal; the datasource path refreshes and then answers 503. Sharing that bookkeeping is a follow-up if wanted.DatasourceConnectionServicewithout a lender (packages/runtime/src/default-datasource-plugin.ts, the managed default datasource that cannot carry the key;packages/cli/src/utils/schema-migrate.ts) refuse anexternal.connectordatasource loudly with the plugin named. Not on this claim's surface.Skills-docsfollow-up feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783 names) can now teachexternal.connector.Human Minimum
packages/specholds no runtime logic.pnpm lintis CI's (eslint over the touched files exits 0 locally).mainmerged back in after feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783 landed).packages/spectouched (the liveness ledger only): its generated shard is regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/🤖 Generated with Claude Code
Generated by Claude Code