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

Merged
objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/project-thread-wu18sj
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 23 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 落地)。

Contract review 6513636834 (FAIL on fb1e8502), answered in 3351ae35

  • Must-fix 1, a plugin driver's connect never judged the connector. attemptConnect's already-registered guard, the only connect a driver adopted from its driver.<name> service reaches, now borrows to judge a datasource naming external.connector. A refusal is failed-credentials through handleFailure: 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 real ObjectQL engine and the real DatasourceAdminServicePlugin through init() and start(), the driver adopted from a driver.sap_orders service the way ObjectQLPlugin.start() adopts it. Ablation: the verdict made false turns 4 of them red.
  • Must-fix 2, a query-parameter API key could reach an error body. @objectstack/types gains borrowedCredentialForms and withoutBorrowedCredential (the value, both URL encodings, base64, and base64 of username:password for basic; the spellings connector-credentials.ts withholds on the connector path). The OData transport scrubs the upstream's refusal message and code with them before it builds ODataRequestError. 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.
  • Should-fix. The lender's docblock now says a handle sees a declaration change after the next reconcile, or on a refresh(), and does not watch the declaration itself.
  • The liveness row's first anchor names the already-registered guard, and the changeset names both fixes.

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 judges the connector at its already-registered guard (a plugin driver adopted from its driver.<name> service; see the review section above), and for a factory-built driver borrows right after the credentialsRef block, 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.
  • The upstream's refusal text is run through withoutBorrowedCredential before it is thrown, so the floor never hands a caller an echoed credential.
  • 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

On 3351ae35 (the review fixes over fb1e8502, where origin/main was merged back in after #27783 landed; the diff against main is this card's files only), after pnpm build (exit 0):

  • pnpm --filter @objectstack/types test: 813 tests passed. service-datasource: 1047 passed. service-automation: 2810 passed. driver-odata: 451 passed.
  • typecheck exit 0 for all four packages; eslint over the touched source and test files exit 0; pnpm --filter @objectstack/spec check:generated exit 0 and check:liveness green after the merge's liveness conflict was resolved (this card's live row kept, its note now naming the OData driver as the first consumer).
  • 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); 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 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.
  • driver-odata (by hand, file restored from a copy, git diff clean afterwards): the borrowed path skipped in transport.ts (6 failed); the lend refusal keeping its 422 (1); a refused borrow cached (1); a plugin auth.scheme allowed beside the connector (1).

Local gates on 3351ae35: node scripts/pm/dispatch-gates.mjs --commands derived 83 families from the 23 changed files; every one was run with its exit code recorded before any pipe, all 83 exit 0, and dispatch-gates --ran reads "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

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 60 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.

37 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 d770b1505e5b172c5a0cea2c22878067da2966b2.

⛔ 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 — 170 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 d770b1505e5b172c5a0cea2c22878067da2966b2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 41d718d8f47207b2c9daef83a94745df0dcba382 — the merge of head 3351ae356419278f5c8ced5c10eccb2ff057f844 into base d770b1505e5b172c5a0cea2c22878067da2966b2, 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 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

⚠️ 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 d770b1505e5b172c5a0cea2c22878067da2966b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (HTTP 429 rate_limit, 2026-10-30T02:25Z, request req_011CgXdQBkCTMLjiPtfbP9jA) · head fb1e850

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 CONTRACT_REVIEW_TIER record.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head fb1e8502e0e07a795ef713bf7c230d36f4034d92 · 2026-10-30T02:31Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: TIER_DEFAULT
Head-sha: fb1e8502e0e07a795ef713bf7c230d36f4034d92
Local-runs: none

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 main), the 78 check-runs on the head, and the cited code read at the head (git show). Handoff 6513433330 read in full. The reviewer tier rendered this record on the declaration face: reviewFace finds no published-schema or governed-rule path in the file list (no packages/spec/src/**, no register row), and the PR body and changeset both carry Clause-②: yes. Not governed; 1869 changed lines, under 3000.

Gate verdicts on the head: all ten required contexts success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Lint UI, Type Check UI, Test UI, Build & E2E UI, Live E2E UI). Check Changeset has one failure run at 01:45:35Z and three later success runs on the same head (the newest 02:28:31Z), consistent with the dev's deviation note that the first run read a body carrying Metadata-protocol: added before #27783 landed; the body now says none. Advisory runs skipped as expected (dogfood matrix, opt-in harnesses). Nothing was built, run or re-run locally.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong.

A. New published seam in @objectstack/types (packages/types/src/connector-credential-source.ts, re-exported from index.ts). CONNECTOR_CREDENTIAL_SOURCE_SERVICE ('connector-credential-source'), BorrowedConnectorAuth (bearer / api-key with headerName and optional paramName / basic), BorrowedConnectorCredential (auth, optional expiresAt), ConnectorCredentialRequest (organizationId), ConnectorCredentialHandle (connector, get, refresh), IConnectorCredentialSource (borrow), connectorCredentialRefusal, BORROWED_CREDENTIAL_SKEW_MS, BorrowedCredentialCallOptions, callWithBorrowedCredential. Right as a seam: the module imports nothing, so the dependency direction the D7 ruling left open is answered without a new edge (service-automation, service-datasource and driver-odata each already depend on, or now depend on, @objectstack/types; objectql untouched). The three auth shapes map one-to-one onto ConnectorInstanceBearerAuthSchema / ConnectorInstanceAPIKeyAuthSchema (headerName, paramName) / ConnectorInstanceBasicAuthSchema (username) in packages/spec/src/shared/connector-auth.zod.ts, read at the head; the lender's borrowedAuthOf switch is exhaustive over ConnectorInstanceAuth minus none.

B. The call helper's rule. callWithBorrowedCredential: get, refresh when expiresAt minus the skew margin is at or before now(), send, on a 401 (result predicate defaulting to status === 401, or a throw declaring status/statusCode 401) refresh once and send again, a second 401 throws 503 SERVICE_UNAVAILABLE naming the instance with a fixed sentence; every other result returns and every other throw propagates, handle refusals included. Right against the card's wording (refresh on expiry, once on 401, then the floor's 503) and against the plugin-driver floor read at packages/objectql/src/external-driver-floor.ts: a thrown error with status 503 is outside EXTERNAL_READ_REFUSED_STATUSES {400, 404, 422} and EXTERNAL_WRITE_REFUSED_STATUSES, so the floor answers ERR_DATASOURCE_UNAVAILABLE with the message in the server log only. 12 unit cases pin each branch, including single flight and the handle-refusal pass-through.

C. The lender (service-automation/src/connector-credential-source.ts, registered in init() and added to providesServices). borrow judges the declaration read by readDeclaredConnectorItems(ctx, { post: true }) (signature verified at the head: returns unknown[] | undefined, throws on an unreadable declaration) and refuses 422 for undeclared / no provider / no auth or type: 'none' / enabled: false, 503 when unreadable. A handle resolves auth.credentialRef through connectorCredentialChain(ctx) (env, then one store.read(ref) row, then the vault; no store under a walled posture) via the existing resolveConnectorCredential; missing is 503 with missingCredentialReason's remedy, unavailable carries the store's own fixed sentences (verified: ConnectorCredentialStore.resolve never interpolates the engine's message), a thrown host resolver is replaced by a fixed sentence. The held value is re-resolved when credentialGeneration moves, which reconcileDeclaredConnectors bumps on entry, and every credential door write runs a reconcile (verified at setConnectorCredential / clearConnectorCredential). Right, with one correction to the module docblock's claim "or the instance's declaration changing under it": a declaration change is only seen when a reconcile runs (generation) or on refresh(); the handle does not watch the declaration itself. Not a defect, since the reconcile is what every declaration path runs, but the docblock overstates it. 15 cases including the real store over SQLite, the five 422 refusals, the post-borrow undeclared case (503 on get), the host-resolver message withheld.

D. The borrower (service-datasource). DatasourceConnectionSpec.credential?: ConnectorCredentialHandle (public type widening on the factory contract, additive); DatasourceConnectionServiceConfig.connectorCredentials?; ConnectableDatasource.external.connector?; DatasourceAdminServiceConfig.connectorRefusal?; the new module datasource-connector-credential.ts (borrowedConnectorOf, borrowConnectorCredential, never throws). In attemptConnect the borrow sits after the credentialsRef block, before the build, answering failed-credentials through handleFailure (state failed, engine marked unavailable, fail-fast when objects bind, verified against handleFailure at the head). The admin door refuses createDatasource before any write, updateDatasource when the patch touches external, and testConnection without probing; the plugin's probe passes the handle to the factory. All right as written, and 14 cases pin them with a factory-built driver double.

E. The first presenter (driver-odata). plugin.ts adds a lazy borrowed() beside credential(): refuses both channels, refuses auth.scheme !== 'none' beside a connector, resolves the source per request through ctx.getService, borrows once per connector name, does not keep a refused borrow, rethrows a lend refusal without its 422 (so the floor answers 503). transport.ts sends through callWithBorrowedCredential when a handle exists, presenting bearer / basic / api-key header / api-key query parameter, the 401 body cancelled unread; the credentialsRef path is unchanged, including resolve-again-on-401. odata-driver.ts threads borrowed to the transport. package.json and pnpm-lock.yaml add the @objectstack/types workspace dependency. 11 new cases. The presentation and refusals are right; two things it implies are wrong, below.

F. The connect-time refusal does not reach the one shipped consumer (must-fix). The OData plugin registers its driver as a driver.-prefixed kernel service in init() ("the engine adopts driver.* services once every plugin has initialised", plugin.ts line 177 to 179); the ObjectQL plugin's start() discovery loop registerDrivers every driver.* service (packages/objectql/src/plugin.ts line 1153); ODataDriver.connect() is a no-op. When the app plugin's start() then calls connectDeclared, attemptConnect returns { status: 'already-registered' } at its first guard (engine.getDriverByName(name)), before the credentialsRef block and before the new borrow block; had the order been reversed, factory.supports(record.driver) is false for a plugin driver id (createDefaultDatasourceDriverFactory.supports is resolveKind(driverId) !== undefined) and skipped-unsupported returns before the borrow block too. So for a datasource served by driver-odata, which the spec makes the only accepted shape for external.connector (built-in database drivers and managed datasources are refused at parse), nothing judges the instance at connect: an undeclared, descriptor-only, credential-less or disabled instance boots as a registered datasource, Setup → Datasources shows no failure, bound objects do not fail the boot, and every read answers 503 ERR_DATASOURCE_UNAVAILABLE with the reason in the server log only. The admin door is not a substitute (it refuses a plugin driver id as unsupported at datasource-admin-plugin.ts line 872, so no runtime-origin OData datasource exists). This contradicts the card's acceptance item "an undeclared instance refused ... at connect", the PR body's "连接 fail-closed 拒绝" and "a datasource that objects bind to fails the boot", the liveness row's first anchor (attemptConnect, which no shipped composition reaches with a connector-naming record), and the carried flag from 6511230689 (a disabled instance surfacing visibly rather than as a silent per-read failure). The reachable branch is pinned only by a factory-built double. Fix at the borrower, where the PR says the gate is: in attemptConnect, judge borrowedConnectorOf(record.external) for an already-registered driver too (a borrow judges the declaration and resolves nothing, so this is a verdict, not a build), recording failed-credentials and evicting or marking the registered driver unavailable, and pin it with a test whose driver is adopted through a driver.-prefixed service rather than built by the factory. Alternatively the OData plugin may borrow at start() (the lender registers in init(), so init-all-then-start-all guarantees it) and fail the boot or warn loudly naming the remedy, but then the liveness evidence and the PR body must stop naming attemptConnect as the OData path's gate.

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 level

Changeset .changeset/27746-datasource-borrows-connector-credential.md: patch on @objectstack/types, @objectstack/service-automation, @objectstack/service-datasource, @objectstack/driver-odata, carrying Clause-②: yes and Metadata-protocol: none, matching the two line-initial declarations in the PR body. What the diff publishes: new exports in @objectstack/types; additive optional fields on DatasourceConnectionSpec, DatasourceConnectionServiceConfig, ConnectableDatasource.external, DatasourceAdminServiceConfig in service-datasource; a new kernel service and a providesServices entry in service-automation; a new runtime dependency and new behaviour in driver-odata. No authorable key, value or type changes (the spec half landed in #27783; this PR edits packages/spec/liveness/** only, which is a ledger, not a schema), so Metadata-protocol: none is the right kind, and under the Post-Task Checklist rule the kind alone decides the level: none takes patch. The four packages named are exactly the four whose published surface moves (packages/spec is touched only in its liveness ledger and generated state-count, which ship no API change). The changeset body carries no model identifier. Right.

Clause-②: yes — this PR declares a widening of the runtime contract (a new seam and a new credential channel for datasources), neither (narrowing) nor skip-changeset; the declaration face is the owed review and this record is it.

③ Boundary flags

Can a credential value reach a log, an error body or a stored row? Traced from every point a value exists: the lender's resolve (resolution.value, held in the handle's closure only), get / refresh returns, callWithBorrowedCredential's credential.auth, presentBorrowed in the transport.

  • Logs: the lender logs nothing; callWithBorrowedCredential logs nothing; the transport's thrown messages use req.subject, never the URL or a header (the fetch-failure catch and ODataRequestError both, verified at the head); the connection service logs borrow.reason, which is the lender's refusal text naming instance and reference only; the floor logs error.message of a driver throw, which for the helper's 503 is the fixed sentence. No value reaches a log from this code.
  • Stored rows: the admin door stores external.connector (a name); the handle is never serialized; the datasource path does not call markRejected. None.
  • Error bodies: the helper's 503 and every lender refusal are fixed sentences; the 401 body is cancelled unread. One path remains open (must-fix). With auth: { type: 'api-key', paramName } the key travels in the request URL (url.searchParams.set). On a non-401 refusal the transport builds ODataRequestError from the dialect's readError(body).message, the upstream's own text, and for 400 / 404 / 422 the floor passes that message to the caller as ExternalReadRefusedError (400 INVALID_QUERY) whenever the where is attested as the caller's own, and to the server log otherwise. An upstream that echoes the request URI in its error message, which gateways and OData services commonly do for an unknown segment, therefore carries the key into a 400 error body shown to an end user. The lender's own call path anticipates exactly this (credentialForms in connector-credentials.ts lists "URL-encoded, as an api-key sent as a query parameter travels in the request URL", and withoutCredential / upstreamRefusalMessage scrub against it); the datasource presenter does not, and cannot import it. Fix: scrub the upstream message against the presented credential's spellings before building ODataRequestError in transport.request (a small helper beside callWithBorrowedCredential in @objectstack/types, so every presenter shares one rule), or refuse the paramName presentation for a borrowed credential until it is scrubbed; pin it with an upstream double that echoes the URL on a 404. The header forms (bearer, basic, api-key header) are not echoed by this path and are not affected.

What happens for a disabled or missing instance. At the lender: borrow refuses 422 (VALIDATION_ERROR) with a sentence naming the instance and the fix; an instance disabled or undeclared after the borrow is refused 503 on the next fill (generation move or refresh). At the admin door: create / external-touching update / test connection refused with the reason, nothing written or probed. At a factory-built driver's connect: failed-credentials, state failed, engine marked unavailable, boot fails when objects bind (pinned). At the OData driver: refused per request before anything is sent, rethrown without the 422 so the floor answers 503 ERR_DATASOURCE_UNAVAILABLE with the reason in the server log only, the borrow asked again on the next request. With no automation plugin: the same shape, naming @objectstack/service-automation. The gap between the third and fourth bullets is ① F.

Dev flags and open questions (os-dev-report 6512624421). open_questions: []. Deviations: (1) Metadata-protocol: added on the body while #27783 was unlanded, returned to none: the body at this head reads none, the changeset reads none, Check Changeset is green; answered. (2) Body opens with the seat signature line: cosmetic, no bearing. out_of_scope_findings: driver-odata wiring was carried into this PR by amendment 3, done; grant-bearing kinds to #26702, right (the handle has D4's shape); the 401-does-not-mark-rejected note is a correct reading of the diff and a legitimate follow-up; runtime default-datasource-plugin and CLI schema-migrate hosts without a lender refuse loudly, verified by the reason text in borrowConnectorCredential; #27783's stale changeset sentence is #27783's file; skills-docs follow-up is the PM's. Nothing escalated beyond the two must-fixes.

Carried items. reconnect's refusal reworded to "when a pool is live, a failed rebuild would restore that pool" with the guard still first and the test comment matched: done. The disabled-instance flag (6511230689): visible at the lender and at the factory path, not at the OData path (① F).

Liveness ledger. external.connector flipped planned to live with two anchors and the note rewritten per should-fix (f) (static kinds resolved once and held; per dispatch only for grant-bearing kinds); state-counts/datasource.md regenerated 30/1 to 31/0; Spec property liveness green. The second anchor (the lender) is reached by the OData path; the first (attemptConnect) is not reached by any shipped composition until ① F is fixed, so the row's evidence should be re-read after the fix.

Must-fix.

  1. ① F: the connect-time fail-closed refusal of external.connector is unreachable for a plugin-driver datasource (already-registered or skipped-unsupported return before the borrow block), so the only shipped presenter boots an undeclared or disabled instance as a connected datasource and fails every read with the cause in the server log only. Gate it in attemptConnect for the already-registered driver, or at the OData plugin's start(), and pin it with a driver adopted through a driver.-prefixed service; align the liveness evidence and PR body with where the gate is.
  2. ③: an API key presented as a query parameter can reach a caller-facing 400 body through an upstream message that echoes the request URI; scrub the upstream message against the presented credential's spellings before building ODataRequestError (or refuse paramName for a borrowed credential), and pin it.

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: claude/project-thread-wu18sj
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Delta review asked · 2026-10-30T03:26Z

Record 6513636834 (FAIL on fb1e8502) is answered on head 3351ae35, one commit over fb1e8502 with no merge from main. needs:contract-review stays on; a delta CONTRACT_REVIEW_TIER record is owed on this head.

  • Must-fix 1: attemptConnect's already-registered guard now borrows to judge a datasource naming external.connector. A refusal goes to failed-credentials through handleFailure. The adopted driver is not evicted, because its plugin owns 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). Five cases on a real engine with DatasourceAdminServicePlugin, with the driver adopted from a driver.-prefixed service.
  • Must-fix 2: @objectstack/types adds withoutBorrowedCredential / borrowedCredentialForms, covering the raw value, both URL encodings, base64, and base64 of user:password. The OData transport scrubs the upstream message and code before it builds ODataRequestError. This is pinned by an upstream double that echoes the request URI.
  • Should-fix: the lender docblock says a handle sees a declaration change after the next reconcile or on refresh().

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 3351ae356419278f5c8ced5c10eccb2ff057f844 · 2026-10-30T03:33Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 3351ae356419278f5c8ced5c10eccb2ff057f844
Local-runs: none

Delta record over 6513636834 (FAIL on fb1e8502, two must-fixes and one should-fix). Inputs: card #27746 (body, claim 6511028605, amendments 6511046968 / 6511235369 / 6512759494, scope addition 6511659940, os-dev-report 6512624421; no newer dev report), PR #27819 (body as rewritten for this head, the 23-file list, the net diff against main), the delta-review request 6514220102, the check-runs on the head, and the cited code read at the head (git show). What moved: exactly one commit, 3351ae35 over fb1e8502, no merge from main (both heads share merge-base 165bc505 with origin/main), 9 files, +226/-11: the changeset's two bullets, driver-odata/src/plugin.test.ts and transport.ts, the lender's docblock, service-datasource's datasource-connector-credential.test.ts and datasource-connection-service.ts, packages/spec/liveness/datasource.json's evidence string, and @objectstack/types' connector-credential-source.ts and its test. Every hunk maps onto one of the three findings of 6513636834 or onto the evidence those findings asked to realign (the liveness anchor, the changeset wording); nothing else moved. The reviewer tier rendered this record on the declaration face as before: reviewFace finds no published-schema or governed-rule path in the file list (no packages/spec/src/**, no register row), and the PR body and changeset both carry Clause-②: yes. Not governed; still under 3000 changed lines.

Gate verdicts on the head: 69 check-runs, every one completed, 60 success and 9 skipped, none failed, none pending at the time of this record (the last to finish, Test Core, at 03:47:23Z). All ten required contexts success: Lint & Repo Gates, TypeScript Type Check (Type Check · workspace included), Test Core (all 8 shards), Build Core, Governed Surface Queue Guard, Lint UI, Type Check UI, Test UI (all 16 shards), Build & E2E UI, Live E2E UI. Also success: both Check Changeset runs, Spec property liveness, Validate Package Dependencies, PR-body guards, Check PR Size, Build Docs. Skipped as expected: the dogfood matrix and CLI verify (no ! in the title, no (narrowing) arm), the opt-in harnesses, Temporal Conformance, UI Published Artifacts. Nothing was built, run or re-run locally.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong. Items A to E of 6513636834 (the @objectstack/types seam, the call helper's rule, the lender, the borrower's factory path and admin door, the OData presenter's presentation and refusals) stand as judged there; the move touches the lender only in its docblock, the borrower only at the already-registered guard, the presenter only in the error-building tail of transport.request, and the seam only by two additive exports. What the move touches is judged afresh below.

F (must-fix 1 of 6513636834): the connect-time refusal now reaches the plugin-driver datasource. Discharged. In attemptConnect (datasource-connection-service.ts lines 549 to 568 at the head) the else if (engine?.getDriverByName?.(name)) branch, the one connect a driver adopted from a driver.-prefixed service reaches (the OData plugin registers its driver in init(), ObjectQLPlugin.start() adopts every driver.* service, AppPlugin.start() then calls connectDeclared on the shared datasource-connection service, verified at packages/runtime/src/app-plugin.ts lines 922 to 937), now reads borrowedConnectorOf(record.external) and, for a datasource naming external.connector, calls borrowConnectorCredential(this.cfg.connectorCredentials?.(), connector) before answering already-registered. A refused borrow (undeclared, descriptor, no auth, disabled, unreadable declaration, or no lender registered) returns handleFailure(record, 'failed-credentials', borrow.reason, ...) with the bound and mapped objects, so the verdict is the same one the factory path gives: connect() records state failed, recordState marks the engine's datasource unavailable (markDatasourceUnavailable, kind failed), and under declared-auto with bound or mapped objects, or bootCritical, the D5 fail-fast throw aborts the boot unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. connectDeclared collects the throw and fails after attempting every datasource, as for any connect failure. The borrow at this guard judges the declaration and resolves no value (the lender's borrow, lines 199 to 201 at the head, throws the 422 before any handle is built; the handle returned on success is discarded here, its get never called), so this is a verdict, not a build, and it adds no credential resolution to the boot. Right, and it is the fix the record asked for, at the borrower, where the PR body says the gate is.

What the fix opens, judged:

  • The adopted driver is not evicted. Right: the registration belongs to the plugin that made it (the 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 rule the file already states: this service evicts only what registeredByThisAttempt names), and the kernel-teardown sweep disconnectAll closes only states with status connected, so a failed-credentials verdict on an adopted driver neither closes nor evicts the plugin's driver. The datasource def the plugin registered is untouched.
  • What a bound object does when the boot is allowed to continue (OS_ALLOW_DRIVER_CONNECT_FAILURE, or no binding so the fail-fast does not fire). Routing consults the driver registry before the unavailable mark (engine-datasource-routing.ts getDriver: this.drivers.get(binding.datasource) first, the mark only when no driver is registered), so a read reaches the OData driver, whose borrowed() asks the lender again, is refused, and rethrows without the 422; the floor answers 503 ERR_DATASOURCE_UNAVAILABLE with the cause in the server log. That is the same per-request behaviour as before the fix, but the cause is now also recorded: Setup → Datasources shows the datasource failed with the lender's reason, and listUnavailableDatasources() names it. The carried flag from 6511230689 (a disabled instance surfacing visibly) is now met on the shipped path.
  • Nothing re-runs the connect. Once the instance is repaired (declared, enabled, auth added) the driver's next request borrows successfully and serves, since the OData plugin does not keep a refused borrow; the retained failed verdict and the engine's mark stay until a restart or a reconnect, which the admin door does not offer for a code-origin plugin-driver datasource. The shared degraded-boot banner's "until it is reachable AND the server is restarted" is therefore accurate about the mark and over-strict about reads for this driver. A stale-loud verdict, not a silent one; noted below as a should-fix, not a defect of the fix.
  • The D2 gate is unchanged. connectDeclared still skips a datasource no object binds to or is mapped to (isDatasourceAddressed), so such an OData datasource gets no verdict at all and is judged per request only, as every unaddressed datasource is. Pre-existing and consistent with ADR-0062 D2; the card's "refused at connect" is about the connect that runs.
  • Hosts without a lender. packages/runtime/src/default-datasource-plugin.ts replays through the shared datasource-connection service (its own asDefault branch is untouched by the move); the CLI's schema-migrate builds a service with no connectorCredentials, where an adopted plugin driver naming a connector would now be failed-credentials naming @objectstack/service-automation rather than already-registered. Fail-closed is the declared posture; right.
  • reconnect evicts first and so never meets the guard; its rebuild path for a plugin driver id (skipped-unsupported, old pool restored) is pre-existing and unreached from the admin door, which refuses plugin driver ids. Untouched.

The pin is what the record asked for: five cases in datasource-connector-credential.test.ts build a real ObjectQL engine and the real DatasourceAdminServicePlugin through init() and start(), register the driver as a driver.sap_orders kernel service, adopt it with registerDriver the way the discovery loop does, and then: the boot throws for a bound datasource whose instance is undeclared; a disabled instance records failed-credentials with the reason, state failed, the engine's unavailable mark, the driver still registered and never called; no lender is refused naming the plugin; a lendable instance answers already-registered with exactly one borrow and no mark; a datasource naming no connector borrows nothing. The factory double's supports answers false for every driver id, so the test cannot pass through the factory path by accident. The dev's ablation claim (the verdict made false reds 4 of 5) is consistent with the fifth case being indifferent to the verdict.

G (must-fix 2 of 6513636834): the presented credential is withheld from the upstream's error text. Discharged. @objectstack/types gains borrowedCredentialForms(auth) (the value; encodeURIComponent of it; the form encoding URLSearchParams writes; unpadded base64; and for basic the unpadded base64 of username:password; forms shorter than four characters dropped; longest first) and withoutBorrowedCredential(text, auth) (every form replaced by [credential withheld]), both re-exported through index.ts's existing export * from './connector-credential-source.js'. In transport.request the auth the helper actually sent is captured (presented = auth inside the call closure, so after a refresh-and-retry it is the second credential, the one the judged response answers), and on a non-accepted, non-OK response both readError(body).message and .code are scrubbed against it before ODataRequestError is built; with no borrowed handle presented is undefined and the credentialsRef path is byte-for-byte as before (that path presents headers only, never a query parameter, so the finding never applied to it). The scrub sits ahead of every exit that carries upstream text: the helper's second-401 503 is a fixed sentence, the fetch-failure and timeout throws name req.subject only, followLink's refusal is fixed. The forms are a superset of the connector call path's credentialForms in connector-credentials.ts (which lacks the URLSearchParams spelling), so the "same spellings" claim holds in the safe direction. Right. The pins: a driver-odata case whose recorded upstream answers a 404 whose message echoes the request URI with the key raw, percent-encoded and form-encoded, asserting the 404 reaches the caller with [credential withheld] and none of the three spellings; and three @objectstack/types cases (every URL spelling and base64 of an api-key; the Authorization: Basic spelling and the bare password; text without a spelling untouched and a value under four characters never blanked). The fixture matcher decodes request URLs before comparing, so the recorded exchange is reached by the real url.searchParams.set presentation.

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 attemptConnect anchor; the lender's docblock is the should-fix. No new behaviour, type, service or dependency beyond the two exported functions.

② Semver level

Changeset .changeset/27746-datasource-borrows-connector-credential.md: patch on @objectstack/types, @objectstack/service-automation, @objectstack/service-datasource, @objectstack/driver-odata, carrying Clause-②: yes and Metadata-protocol: none, matching the two line-initial declarations in the PR body (unchanged by the move; Check Changeset on the head is success). The move adds to what the diff publishes two exported functions in @objectstack/types (borrowedCredentialForms, withoutBorrowedCredential, additive) and a new verdict on an existing ConnectStatus branch in service-datasource (no type change: failed-credentials already existed); it changes no authorable key, value or type, so Metadata-protocol: none is still the right kind and the kind alone decides the level: none takes patch. The four packages named are still exactly the four whose published surface moves; packages/spec is touched only in its liveness ledger. The changeset body carries no model identifier. Right; the rest of ② stands as judged in 6513636834.

Clause-②: yes — this PR declares a widening of the runtime contract (a new seam and a new credential channel for datasources), neither (narrowing) nor skip-changeset; the declaration face is the owed review and this record is it.

③ Boundary flags

Can 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 (borrow resolves nothing; the handle is dropped unused); borrow.reason is the lender's refusal text, which names the instance and the reference only, and it goes into the retained state, the warn log and the fail-fast message, as the factory path's reason already did. The scrub closes the one open path the previous record named: an upstream message echoing a query-parameter API key now reaches the caller's 400 body as [credential withheld]. odataCode is scrubbed too. The header forms were not affected and remain so. Closed.

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 failed-credentials with the lender's reason, marks the datasource unavailable and fails the boot when objects bind to it; if the boot continues, every read is still refused per request by the driver (503, cause in the server log) and the cause is visible in Setup → Datasources. The gap the previous record's fourth bullet named is closed.

Dev flags and open questions. No new os-dev-report; the PR body's "Contract review 6513636834 ... answered in 3351ae35" section is the dev's account of the move, and each of its four claims is verified above (guard borrows, failed-credentials through handleFailure, driver not evicted, five cases on a real engine; the two helpers and their spellings, scrub before ODataRequestError, the echoing double; the docblock; the liveness anchor and changeset). open_questions: [] and the two deviations of 6512624421 stand as answered in 6513636834. The delta request 6514220102's description of the move (one commit, no merge from main) matches git log.

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 refresh()." Matches get / refresh as read.

Liveness ledger. The external.connector row's first anchor now names the already-registered guard as the connect a plugin driver reaches, and that guard is reached by the shipped composition (AppPlugin.start() to connectDeclared to attemptConnect), so both anchors are live code on a reached path; Spec property liveness is success on the head.

Carried items. reconnect wording: done, unchanged by the move. Disabled-instance flag (6511230689): now visible on every path.

Must-fix. None.

Should-fix (non-blocking).

  1. For an adopted plugin driver, the retained failed-credentials verdict and the engine's unavailable mark outlive the repair of the instance (the driver serves again on its next request, nothing clears the verdict until a restart), and the degraded-boot banner says reads fail "until ... the server is restarted", which is true of the mark and not of this driver's reads. Either clear the mark when a later request on that datasource borrows successfully, or say in the guard's comment and the banner that for an adopted driver the verdict describes the boot-time judgment, not the live state.
  2. connector-credentials.ts's credentialForms and @objectstack/types' borrowedCredentialForms are two lists of the same rule, and the first lacks the form-encoding spelling; service-automation already depends on @objectstack/types, so the connector path can take its forms from the shared helper.

Implemented-by: claude/project-thread-wu18sj
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27819 @ 3351ae356419278f5c8ced5c10eccb2ff057f844 · 2026-10-30T03:51Z

维护者速读

  • 改了什么: D7 的运行时一半。数据源 external.connector 点名的连接器实例,其授权现在真的能借到:@objectstack/types 新增一个按实例绑定的凭据句柄(get / refresh)和调用助手(过期刷新、401 刷新重试一次,之后返回 503),service-automation 负责提供,service-datasource 在连接时查找。OData 驱动已接上。实例未声明或已停用时,连接直接失败(fail closed),不会发出无认证请求;错误信息里的凭据会被抹掉。
  • 为什么改: D7 裁决(6486136301):一次授权让流程动作和虚拟对象共用。spec 一半(feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783)已合并,这是让它生效的一半;账本行翻成 live。
  • 风险与回滚: 只影响声明了 external.connector 的数据源(此前这个键尚未生效);credentialsRef 路径不变。changeset patch · Clause-②: yes(新增公开接口)。还原即回滚。
  • 先看: packages/types/src/connector-credential-source.ts;packages/services/service-datasource/src/datasource-connection-service.ts 的借用与"已注册驱动"判定。
  • 两分钟验证: pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-connector-credential.test.ts。
  • 需要你做的: 无,席位落地。

席位意见:ACCEPT。契约复核:6513636834 在 fb1e8502 判 FAIL(两个 must-fix),修复后 delta 记录 6514494060 在当前 head 判 PASS。本 PR 不含 packages/spec/src/**,仅凭 Clause-②: yes 欠复核,所以档位是 TIER_DEFAULT。无 must-fix。

席位核对

  1. Shape: +2052/−36, 23 files. A new seam in @objectstack/types (the handle, the call helper, withoutBorrowedCredential / borrowedCredentialForms); the provider and its registration in service-automation; the lookup and the connect-time and already-registered refusals in service-datasource; driver-odata borrowing through the helper; the liveness row flipped to live.
  2. Premise measured: on main, after feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783, external.connector was declared and refused at build, but nothing read it at runtime; the liveness row was planned. Must-fix 1 (an unreachable refusal for an adopted plugin driver) and must-fix 2 (a query-parameter API key echoed in a 400 body) are each pinned, and the ablations go red.
  3. Gate check-runs on 3351ae35: 69 runs: 60 success, 9 skipped, 0 failed. mergeable_state: clean. Local: 83 derived gates, all exit 0.
  4. Accepted deviations: the surface took driver-odata's README and package.json (the new @objectstack/types dependency). The PR body's attribution block sits on lines 2–3, because the relay's body patch requires the seat signature on line 1.
  5. Out of scope, filed: the delta record's two should-fixes (an adopted driver's boot-time verdict that does not clear after repair; the connector path's duplicate credentialForms missing the form-encoded spelling) are connector credential borrowing follow-ups of #27746: an adopted plugin driver's boot-time failed-credentials verdict clears once the instance is repaired, and the connector path reuses borrowedCredentialForms — epic #27261 #27919. Skills-docs: skills + docs: teach external.connector (D7) beside credentialsRef in the datasources skill rule and the two data-modeling guides — epic #27261 P2 #27786 (now unblocked).
  6. Landing: not governed (check-governed-merges --pr 27819: 2088 lines ≤ 3000). A same-shape PASS stands on the current head at the face's tier, so needs:contract-review comes off. Landed by the seat through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 03:51
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 03:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit ed8b237 Oct 30, 2026
73 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-wu18sj branch October 30, 2026 04:28
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