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): 37 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 — 170 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 41d718d8f47207b2c9daef83a94745df0dcba382 && git checkout 41d718d8f47207b2c9daef83a94745df0dcba382
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d770b1505e5b172c5a0cea2c22878067da2966b2 3351ae356419278f5c8ced5c10eccb2ff057f844 && git checkout -B drift-repro d770b1505e5b172c5a0cea2c22878067da2966b2 && git merge --no-ff 3351ae356419278f5c8ced5c10eccb2ff057f844
node scripts/docs-audit/affected-docs.mjs --json d770b1505e5b172c5a0cea2c22878067da2966b2
|
…pool is restored only when one is live Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV The seat's at-tier review call was refused by the model quota, and per contract-review.md the tier is not lowered. Owed on this head: one same-shape
|
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: Generated by Claude Code |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: Inputs: card #27746 (body, claim 6511028605, amendments 6511046968 / 6511235369 / 6512759494, scope addition 6511659940, os-dev-report 6512624421), PR #27819 (body, 23-file list, net diff against Gate verdicts on the head: all ten required contexts ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong. A. New published seam in B. The call helper's rule. C. The lender ( D. The borrower ( E. The first presenter ( F. The connect-time refusal does not reach the one shipped consumer (must-fix). The OData plugin registers its driver as a G. A query-parameter API key can reach a caller-facing error body (must-fix, judged in ③, named here because it is a property of the new presenting surface). ② Semver levelChangeset
③ Boundary flagsCan a credential value reach a log, an error body or a stored row? Traced from every point a value exists: the lender's
What happens for a disabled or missing instance. At the lender: Dev flags and open questions (os-dev-report 6512624421). Carried items. Liveness ledger. Must-fix.
Should-fix. The lender docblock's "or the instance's declaration changing under it" overstates what a handle watches; say "after the next reconcile". Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ed connector at connect, and withhold the presented credential from upstream error text Contract review 6513636834 on fb1e850: - attemptConnect's already-registered guard (the only connect a plugin driver adopted from its driver.<name> service reaches) now borrows to judge a datasource naming external.connector, recording failed-credentials (and failing the boot when objects bind) instead of answering available. - @objectstack/types gains withoutBorrowedCredential / borrowedCredentialForms; the OData transport scrubs the upstream's refusal text with them, so a query-parameter API key echoed in a request URI never reaches an error body. - The lender's docblock no longer claims a handle watches the declaration. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV Record 6513636834 (FAIL on
|
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: Generated by Claude Code |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: Delta record over 6513636834 (FAIL on Gate verdicts on the head: 69 check-runs, every one ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong. Items A to E of 6513636834 (the F (must-fix 1 of 6513636834): the connect-time refusal now reaches the plugin-driver datasource. Discharged. In What the fix opens, judged:
The pin is what the record asked for: five cases in G (must-fix 2 of 6513636834): the presented credential is withheld from the upstream's error text. Discharged. Nothing else. The changeset's two bullets now name the already-registered verdict and the scrub; the liveness evidence string adds "the same verdict at its already-registered guard" to the ② Semver levelChangeset
③ Boundary flagsCan a credential value reach a log, an error body or a stored row? Re-traced for the code the move adds. The guard's borrow produces no value ( What happens for a disabled or missing instance. Lender, admin door and factory-built connect: as judged in 6513636834. At the shipped OData path, now: the boot connect records Dev flags and open questions. No new os-dev-report; the PR body's "Contract review 6513636834 ... answered in Should-fix of 6513636834 (lender docblock). Done: "until one of two things ... The handle does not watch the declaration itself: a change to the instance ... is seen after the next reconcile, which every declaration path runs, or on a Liveness ledger. The Carried items. Must-fix. None. Should-fix (non-blocking).
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV 维护者速读
席位意见:ACCEPT。契约复核:6513636834 在 席位核对
|
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 刷新一次后重试、未声明与被禁用的实例在连接时被拒。Contract review 6513636834 (FAIL on
fb1e8502), answered in3351ae35attemptConnect's already-registered guard, the only connect a driver adopted from itsdriver.<name>service reaches, now borrows to judge a datasource namingexternal.connector. A refusal isfailed-credentialsthroughhandleFailure: the datasource shows failed with the reason, it is marked unavailable, and the boot fails when objects bind to it. The adopted driver is not evicted, because it belongs to the plugin that registered it (datasource DELETE does not evict the stuck driver from the data-engine driver registry — /ready keeps naming a datasource that no longer exists, recoverable only by process restart #13578) and it refuses each request on its own. Pinned by 5 cases on a realObjectQLengine and the realDatasourceAdminServicePluginthroughinit()andstart(), the driver adopted from adriver.sap_ordersservice the wayObjectQLPlugin.start()adopts it. Ablation: the verdict made false turns 4 of them red.@objectstack/typesgainsborrowedCredentialFormsandwithoutBorrowedCredential(the value, both URL encodings, base64, and base64 ofusername:passwordfor basic; the spellingsconnector-credentials.tswithholds on the connector path). The OData transport scrubs the upstream's refusal message and code with them before it buildsODataRequestError. Pinned by an upstream double that echoes the request URI on a 404, and 3 unit cases. Ablation: the scrub removed turns the driver case red.refresh(), and does not watch the declaration itself.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.attemptConnectjudges the connector at its already-registered guard (a plugin driver adopted from itsdriver.<name>service; see the review section above), and for a factory-built driver borrows right after thecredentialsRefblock, 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.withoutBorrowedCredentialbefore it is thrown, so the floor never hands a caller an echoed credential.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
On
3351ae35(the review fixes overfb1e8502, whereorigin/mainwas merged back in after #27783 landed; the diff againstmainis this card's files only), afterpnpm build(exit 0):pnpm --filter @objectstack/types test: 813 tests passed.service-datasource: 1047 passed.service-automation: 2810 passed.driver-odata: 451 passed.typecheckexit 0 for all four packages;eslintover the touched source and test files exit 0;pnpm --filter @objectstack/spec check:generatedexit 0 andcheck:livenessgreen after the merge's liveness conflict was resolved (this card'sliverow kept, its note now naming the OData driver as the first consumer).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);packages/drivers/driver-odata/src/plugin.test.ts(11 new: 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).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.driver-odata(by hand, file restored from a copy,git diffclean afterwards): the borrowed path skipped intransport.ts(6 failed); the lend refusal keeping its 422 (1); a refused borrow cached (1); a pluginauth.schemeallowed beside the connector (1).Local gates on
3351ae35:node scripts/pm/dispatch-gates.mjs --commandsderived 83 families from the 23 changed files; every one was run with its exit code recorded before any pipe, all 83 exit 0, anddispatch-gates --ranreads "83 derived, 83 run, 0 NOT-MEASURED" (a derived zero). 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