Skip to content

feat(cli): os serve publishes its datasource secret store's resolve as the datasource-secret-resolver service, so a stack's OData plugin resolves a sys_secret credentialsRef with no code - #27952

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

epic:#27261#1 · marchtian · session_017ihL2fy9PSGEZJdJpoPvzx

Requested by 田騏 · project thread

Fable review owed. Developed on Opus while the Fable quota is out. Clause-②: yes (widening) (a new published kernel service), so it lands after a contract review PASS on the current head.

Fixes #27875

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么: os serve 把它为数据源建的密钥解析器的「读」那一半(resolve)注册成内核服务 datasource-secret-resolver。OData 插件没拿到 options.secrets 时,在第一个请求时去读这个服务,于是 credentialsRef: 'sys_secret:…' 在 os serve 上不用写代码就能解析。显式传的 options.secrets 仍然优先;两者都没有时照旧拒绝请求,什么都不发出去,报错里点名这个服务。
  • 为什么改: 声明式 OData 的卖点是只写元数据。凭据这一步要作者自己写 resolve,卖点就破了(docs(data-modeling): an OData datasources guide, and the three-way choice between declarative OData, a plugin driver and a synced real object #27871 写指南时发现)。
  • 风险与回滚: 只发布 resolve,bind/unbind 仍只在数据源管理插件手里;它只解得开 datasource_credential 作用域封的行,设置项密钥和密钥字段的行解不开(有测试)。没有加密提供者时 os serve 不建解析器,也就不注册服务。进程内任何插件都能调这个 resolve;为什么可以接受(信任边界是进程,引擎的 resolveSecret 已对进程内调用方解密密钥字段),见 What changes 一节。回滚即 revert。
  • 文档: OData 指南(odata-datasources.mdx,docs(data-modeling): an OData datasources guide, and the three-way choice between declarative OData, a plugin driver and a synced real object #27871 已落地)去掉了「os serve 下要自己传 resolve」的说明,改为:os serve 下不传 secrets,凭据走服务端自己的密钥存储。
  • 先看: packages/types/src/datasource-secret-resolver.ts(服务名、接口、插件驱动该怎么读);packages/cli/src/commands/serve.ts 的 createDatasourceSecretResolverPlugin 和挂载处;packages/drivers/driver-odata/src/plugin.ts 的 hostSecretResolver。
  • 两分钟验证: pnpm --filter @objectstack/cli exec vitest run src/commands/serve-datasource-secret-resolver.test.ts,预期 5 个用例全绿:真内核加真 binder,绑定的凭据经服务解出原文,服务上只有 resolve,别的生产者封的行解不开。
  • 需要您做的: 无。契约复审 PASS 后落地。

What changes

The service and its contract (packages/types/src/datasource-secret-resolver.ts): DATASOURCE_SECRET_RESOLVER_SERVICE ('datasource-secret-resolver') and IDatasourceSecretResolver (resolve(credentialsRef), answering undefined for a handle that is not the binder's, a missing row or a decrypt failure). The module states how every plugin driver that dereferences an external.credentialsRef reads it: an explicit secret store handed to the plugin first; the service looked up at the first request, never at init() (AGENTS.md, "Startup registry reads"); fail-closed when neither resolves. It lives in @objectstack/types because the producer (os serve) and the consumers (plugin drivers, in this repository or out of it) never import each other, and the package depends only on @objectstack/spec. The card asked whether other plugin drivers should read it the same way: yes, and the module is where that is written down.

The producer (packages/cli/src/commands/serve.ts): createDatasourceSecretResolverPlugin(secrets) (plugin com.objectstack.cli.datasource-secret-resolver, providesServices: ['datasource-secret-resolver']) registers a resolver whose one method calls the binder's resolve. run() mounts it right after DatasourceAdminServicePlugin, with the same binder, and only when a binder was built. No crypto provider means no binder, so no service, and a driver's refusal names the service.

The consumer (packages/drivers/driver-odata/src/plugin.ts, the secrets fallback only): const store = secrets ?? hostSecretResolver(ctxRef), evaluated inside the per-request credential() provider. hostSecretResolver catches the kernel's throw on an unregistered name, so the miss reaches the plugin's own refusal, not the kernel's "not found" text. The refusal message gains the service name and the two remedies (run under os serve, or pass options.secrets). It reads the service through @objectstack/types, which the package already depends on since #27819.

The guide (content/docs/data-modeling/odata-datasources.mdx, landed with #27871 while this was in progress): the mount example drops its secrets: erpSecretStore line and import; the option table says an absent secrets falls back to the host's datasource-secret-resolver service, which os serve registers; the Credentials section replaces the "pass your own resolve on os serve" caveat with the two cases (under os serve, pass nothing; a host composing its own kernel passes secrets or registers the service) and the fail-closed list says "neither secrets nor the service".

Who can call resolve, and why that is acceptable. Any plugin in the process can read the service and call resolve() on any sys_secret: handle. It is not narrowed to the calling datasource's own credentialsRef, because the service cannot tell who calls it, and the measured alternatives narrow nothing real:

  • The trust boundary is the process, not the plugin. A plugin is code the operator installed and runs with the kernel's own reach: it can read sys_secret rows under the system context through the data engine, and the engine's public resolveSecret already decrypts a secret field's secret: reference for any in-process caller (packages/objectql/src/engine.ts, resolveSecret). This service adds no reach of a new kind; it adds the datasource credentials, through the binder the datasource admin already holds.
  • What it can open is bounded by scope. The binder decrypts under the datasource_credential crypto scope alone, so a settings secret or a secret field's row fails authentication and answers undefined (pinned in the new cli test). bind and unbind are not on the service.
  • "Only a handle some declared datasource names" would not narrow it. Every datasource definition, with its credentialsRef, is readable by any plugin through the engine's listDatasourceDefs(), so such a check excludes only orphaned handles while adding a second read on every request. A per-caller restriction needs a caller identity the kernel's service registry does not carry.
  • The alternative a stricter host has is to not publish it: os serve mounts it only when it mounts the datasource admin itself, and a host composing its own kernel registers the service or passes secrets explicitly.

Ledger: scripts/engine-double-contract.pinned.json gains the new test's sys_secret double (its delete routes through assertEngineDeleteDispatch), written by check-engine-double-contract.mjs --write as the gate asks.

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

Why

Epic #27261 P2 (ruling 6486145005, Tier B = declarative OData). Found by #27747's carrier (#27871, os-dev-report 6513440511): on origin/main, serve.ts builds createDatasourceSecretBinder and hands it only to DatasourceAdminServicePlugin, so a stack's OData plugin cannot resolve a sys_secret: handle under os serve.

Verification

On cf00041b (origin/main merged in three times, last after #27819 landed at ed8b2375; #27871 and the driver-odata conformance kit came in the merge before), after pnpm install --frozen-lockfile and a full build of ./packages/** (exit 0):

  • New tests. packages/cli/src/commands/serve-datasource-secret-resolver.test.ts (5): inside a real ObjectKernel over a real binder (createDatasourceSecretBinder with LocalCryptoProvider), a credential bound through the binder resolves through the service to its cleartext; the service's only key is resolve; a settings-scoped row in the same sys_secret table, a missing row and a foreign handle all answer undefined; providesServices names the service; and a source pin (comments masked) that run() mounts the plugin inside if (secrets) with that binder and defines it once. packages/drivers/driver-odata/src/plugin.test.ts (5 new): the handle resolves through the host's service with no options.secrets and the service is asked exactly once; an explicit options.secrets wins and the service is never asked; a handle the service does not resolve is refused with nothing sent; a service registered after the plugin's init is seen at the first request; and on a real kernel with neither, the refusal is the plugin's own message naming the service, never the kernel's text, with nothing sent. The existing "no secret store" refusal row is unchanged.
  • Ablations, each red then restored (git status --porcelain empty after each): the fallback removed (const store = secrets): 3 failed; precedence flipped (service before options.secrets): 1 failed; the getService try/catch removed: 1 failed (the real-kernel case); the whole binder registered instead of the read half: 1 failed.
  • Suites: driver-odata 732 passed (the connector-credential cases included); types 832 passed; cli the two serve-datasource-* files, 11 passed. typecheck exit 0 for types, driver-odata and cli (the cli run includes check:test-typecheck).
  • Local gates: node scripts/pm/dispatch-gates.mjs --commands derived 101 families from the changed files on that head; every one was run with its exit code recorded before any pipe, and dispatch-gates --ran reads "101 derived, 101 run, 0 NOT-MEASURED (a DERIVED zero)" (112 of 112 on the pre-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 head 33bc41fb). PR CI on cf00041b: every required check success. An earlier pass on the pre-guide head was red on three gates about the new test's double, all fixed in the diff: check:engine-double-contract (the double's delete now routes through assertEngineDeleteDispatch, ledger row written), check:where-matcher (the operator refusal moved inside the matcher callback) and check:cross-package-test-inputs (a doc comment named another package's test path). 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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (Not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint is CI's.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx

…as the datasource-secret-resolver service; driver-odata reads it when passed no secret store

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…d refuses operators

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…patch, refuses operators inside its matcher, and names no other package's path

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…Ref resolves through the server's own secret store, with no secrets option

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

# Conflicts:
#	packages/drivers/driver-odata/src/plugin.test.ts
#	packages/drivers/driver-odata/src/plugin.ts
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/driver-odata, @objectstack/types, touching 6 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/drivers/driver-odata/README.md, packages/types/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/odata-datasources.mdx (via DATASOURCE_SECRET_RESOLVER_SERVICE (symbol, a top-level const object), createODataDriverPlugin (symbol, a top-level function))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/drivers/driver-odata/README.md, packages/types/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 43 pages)
  • 2 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 — 40 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 ed0e71db94a73acc02512ff2ee0655106d02e585 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5713bbb2296a7ebaae7ae31463f44248267481b6 — the merge of head cf00041b89839fdb60bc1b9c73b5fefa976b8be6 into base ed0e71db94a73acc02512ff2ee0655106d02e585, 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 5713bbb2296a7ebaae7ae31463f44248267481b6 && git checkout 5713bbb2296a7ebaae7ae31463f44248267481b6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed0e71db94a73acc02512ff2ee0655106d02e585 cf00041b89839fdb60bc1b9c73b5fefa976b8be6 && git checkout -B drift-repro ed0e71db94a73acc02512ff2ee0655106d02e585 && git merge --no-ff cf00041b89839fdb60bc1b9c73b5fefa976b8be6

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

⚠️ 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 ed0e71db94a73acc02512ff2ee0655106d02e585 → 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

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

① Derived judgments

  • Published surface widened, nothing narrowed. @objectstack/types gains DATASOURCE_SECRET_RESOLVER_SERVICE ('datasource-secret-resolver') and IDatasourceSecretResolver { resolve(ref): Promise<string | undefined> }. Both are new exports with no existing name changed. Correct as Clause-②: yes (widening). No packages/spec/src/** or governed path is in the file list (check-governed-merges --pr 27952: 0 of 10 hit), so the declaration face is the only face and the tier is TIER_DEFAULT.
  • Producer. createDatasourceSecretResolverPlugin(secrets) publishes a fresh object whose only key is resolve, delegating to the binder. It is mounted inside if (secrets) immediately after DatasourceAdminServicePlugin, with the same binder. I read createDatasourceSecretBinder.resolve on main (service-datasource/src/datasource-secret-binder.ts). It decrypts under AAD { scope: 'datasource_credential', namespace, key } and answers undefined on any miss or decrypt failure, never throwing. So the PR's "only datasource credentials" claim holds, and the interface's "never throws for those" matches the producer.
  • Consumer. In driver-odata, const store = secrets ?? hostSecretResolver(ctxRef) is evaluated inside the per-request credential(), not in init, so the startup-registry rule holds. An explicit options.secrets wins. hostSecretResolver catches the kernel's throw on a missing service and checks typeof resolve === 'function'. A miss, or an unresolved handle, still refuses the request before anything is sent, so fail-closed is kept. The borrowed-connector path (borrowed(), from 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) is untouched by the merge resolution.
  • Exposure. Any in-process plugin can call resolve() on any sys_secret: handle. I accept the PR body's argument. The boundary is the process: engine.resolveSecret already decrypts secret-field refs for in-process callers, and every credentialsRef is readable via listDatasourceDefs(), so a "declared refs only" check would exclude only orphaned handles. The scope AAD bounds what opens.
  • Docs. The README and odata-datasources.mdx now say an absent secrets falls back to the service under os serve. That matches the code. The prose faces were checked by the seat, not this record.

② Semver level

Metadata-protocol: none with a widening of a non-protocol surface ⇒ patch (AGENTS.md: a non-protocol change ships patch). The changeset has patch on @objectstack/types, @objectstack/cli and @objectstack/driver-odata, the three packages the diff moves. Consistent.

③ Boundary flags

  • The dev's one open point (exposure) is answered in ① above: accepted, not restricted.
  • Collision. A stack that registers its own datasource-secret-resolver under os serve fails boot loudly with "already registered". That is acceptable and documented in the PR body.
  • Author. The PR user.login is objectstack-fleet[bot]; no relay-bypass note is needed.
  • scripts/engine-double-contract.pinned.json only grows (one coverage row for the new test double).

Implemented-by: claude/project-thread-vfnybu
Reviewed-by: session_017ihL2c5z3q4h8inrn5kwqV

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27952 @ cf00041b89839fdb60bc1b9c73b5fefa976b8be6 · 2026-10-30T05:46Z

维护者速读

席位意见:ACCEPT。契约复核 PASS 在案(6515948918,TIER_DEFAULT,声明面,就是当前 head)。无 must-fix。

席位核对

  1. Shape: +423/−32 across 10 files: the types contract, the cli producer plugin and its mount, the driver-odata fallback, the README and guide, one ledger row, and the changeset.
  2. Premise measured: on main, serve.ts handed the binder only to DatasourceAdminServicePlugin, so a stack's OData plugin had no way to resolve a sys_secret: handle (os-dev-report 6513440511 on docs: the external-systems guide gains the OData datasource section and the three-way choice (declarative OData, plugin driver, DataSync mirror) — epic #27261 P2 #27747).
  3. Gate check-runs on cf00041b: 54 runs: 43 success, 11 skipped, 0 failed. Local: 101 derived gates, all exit 0.
  4. Accepted deviations: the exposure is argued, not restricted, as the record accepts. One file is outside the claim: scripts/engine-double-contract.pinned.json, which only grows. The README secrets row rides in the merge commit, as the PM asked.
  5. Out of scope: none.
  6. Landing: not governed (check-governed-merges --pr 27952: 0 of 10 paths hit; 455 lines ≤ 3000). A same-shape PASS stands on the current head, so needs:contract-review stays off. The seat lands it through the relay.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants