Skip to content

feat(cli): os serve seals secrets with the crypto provider a plugin registers - #30993

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-bimizv
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-bimizv

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #30948
Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:os serve 现在用插件在初始化时注册的密钥加密提供方(服务名 crypto-provider)封存全部机密,即对象的机密字段、数据源凭据和加密设置;没有插件注册时仍用原来的本地提供方。
  • 为什么改:云端客户自管密钥(cloud#3573)要让 KMS 接管密钥,而此前 os serve 启动收尾时总会建本地提供方并覆盖插件设好的那个,KMS 接不进来。
  • 风险与回滚:风险在密钥持有边界,选错提供方会让机密打不开,所以两个插件同时声明、初始化之后才注册、注册后被替换、注册的不是提供方这四种情况都在启动时拒绝,拒绝文字不带任何密钥值;无注册时行为不变,唯一差别是默认设置服务改为与数据引擎共用同一个本地提供方实例(密钥来源相同,互相能解);回滚即 revert 本 PR,不涉及数据迁移。
  • 先看:packages/cli/src/commands/serve.ts 的 createHostCryptoProvider(选哪个提供方、何时拒绝),同文件启动后接线处的 hostCrypto.provider.confirm(),再看测试 packages/cli/src/commands/serve-host-crypto-provider.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/cli exec vitest run src/commands/serve-host-crypto-provider.test.ts,预期 16 条全部通过。
  • 需要你做的:无,席位落地。

What changes

A plugin supplies the host's secret crypto provider by registering an ICryptoProvider as the kernel service crypto-provider with ctx.registerService in its init(), and declaring it in providesServices. The name is exported as CRYPTO_PROVIDER_SERVICE from @objectstack/types, beside its precedent DATASOURCE_SECRET_RESOLVER_SERVICE, so objectstack-ai/cloud imports it instead of a literal. No spec entry: CoreServiceName is the dispatcher's slot vocabulary, and the registered value's type is the existing ICryptoProvider contract, unchanged.

os serve (packages/cli/src/commands/serve.ts):

  • One host provider. createHostCryptoProvider builds one ICryptoProvider that delegates to the provider the boot chooses, plus a small host plugin (com.objectstack.cli.host-crypto-provider) that keeps the plugin context. The choice is made at kernel:ready, after every init(): the registered provider when there is one, otherwise the deferred LocalCryptoProvider serve already built. That one instance is handed to the data engine (setCryptoProvider, secret fields), the datasource secret binder (datasource credentials) and the default SettingsServicePlugin (encrypted settings, through its existing cryptoProvider option).
  • No registration. The fallback is the same deferred LocalCryptoProvider, keyed off OS_SECRET_KEY or the persisted key file as before; its production refusal for an unstable key still fires at kernel:ready, unchanged. LocalCryptoProvider itself is not touched. The one difference on this path: the default settings service now shares that one local instance instead of constructing its own new LocalCryptoProvider() from the same key source (the seat accepted this; a test pins that a value either one sealed opens under the other).
  • Two registrations refuse at boot. Before runtime.start(), refuseTwoCryptoProviderPlugins refuses a composition where more than one plugin (config-mounted or resolver-mounted, deduplicated by name) declares providesServices: ['crypto-provider'], naming both. An undeclared second registerService('crypto-provider') is refused by the kernel itself (it names the service, not the plugins; recording the registrant in kernel.ts is out of scope per the claim).
  • The deferred path. The binder is built before plugins initialize, so it holds the host provider, which chooses nothing until every init() has run. A secret sealed or opened while plugins are still initializing is refused (HostCryptoProviderRefusal) rather than sealed by a provider the boot has not chosen. A credential written before the post-start wiring is sealed by the registered provider.
  • After start. hostCrypto.provider.confirm() refuses a provider registered after init (the boot had already chosen the local one) and a registered provider replaced after the choice. A value registered under the name that lacks the provider's methods refuses, naming the missing methods. Every refusal names the service and plugins, never a registered value. A HostCryptoProviderRefusal or the production key refusal stops the boot; any other wiring failure keeps the old warn-and-continue.

Tests

packages/cli/src/commands/serve-host-crypto-provider.test.ts (new, 16 tests, integration tier by vitest-tiers.ts), over a real ObjectKernel, the real host plugin, a real ObjectQL with a secret field, the real SettingsService, and a test KMS provider that wraps LocalCryptoProvider with its own key and records each scope it sealed:

  • a provider registered during init seals secret-field writes, datasource credentials and encrypted settings (scopes object_secret_field, datasource_credential, settings), and the local fallback is never built;
  • no registration: the local provider is chosen at kernel:ready from the same key source as new LocalCryptoProvider(); an encrypted setting opens under a LocalCryptoProvider built the original way and the other way round; a secret field sealed by it opens under the original provider;
  • two declaring plugins are refused before either initializes, both named; one plugin, or one plugin composed twice under its name, is not; an undeclared second registration is refused by the kernel;
  • the deferred path: a credential bound at kernel:ready is sealed by the registered provider; a seal during init is refused in both plugin orders and records nothing; late registration and replacement refuse; a non-provider value refuses naming what it lacks;
  • no refusal message carries the test keys (asserted on every refusal);
  • source pins on run(): one host provider over the deferred local one, its plugin mounted, the same provider handed to the binder, the default settings service and the engine, no new LocalCryptoProvider( left, and the declaration check before runtime.start().

Acceptance notes

  • Out of this card's surface, noted for cloud#3573: the one-shot CLI commands that open secrets outside os serve (one-shot-settings.ts, os secret rewrap, os secret orphans) build their own LocalCryptoProvider and do not consult a registered provider. A KMS-keyed deployment needs them adopted separately.
  • Plugins mounted by registerInstalledPlugins (installed packages) are not in the declaration check; the kernel's own refusal of a second registration still applies to them.
  • A SettingsServicePlugin the app composes itself (not the default capability) is not handed the host provider; it builds its own as before.
  • A plugin that declares the service but never registers it falls back to the local provider; it is not refused.
  • Owed before landing: the CONTRACT_REVIEW_TIER review named in the claim (the key-holder boundary).

Verification

Premise on main 256a4b7, and the same boot on this branch (real os dev --fresh boot)

A probe app (outside the repo) mounts a plugin that registers a test provider as crypto-provider in init() and also calls setCryptoProvider with it in start(), then inserts a row into an object with a secret field and prints which scopes its provider sealed.

On main 256a4b7, the plugin's provider is overwritten:

  ↪ secret fields: LocalCryptoProvider wired (dev) — set OS_SECRET_KEY and swap for KMS/Vault in production
PROBE engine holds the plugin provider itself: false | holder type: Object | keys: keySource,resolve,encrypt,decrypt,rotateKey,digest,keyedDigest
PROBE secret-field insert ok
PROBE scopes sealed by the plugin provider: []

On this branch, the same probe:

  ↪ secret fields: the crypto provider a plugin registered as 'crypto-provider' wired
PROBE engine holds the plugin provider itself: false | holder type: Object | keys: encrypt,decrypt,rotateKey,digest,keyedDigest,resolve,confirm,source
PROBE secret-field insert ok
PROBE scopes sealed by the plugin provider: ["object_secret_field"]

(The engine holds the host provider, which delegates to the registered one: the plugin's provider sealed the write.) The same probe with the registration switched off keeps the original line ↪ secret fields: LocalCryptoProvider wired (dev) and its provider seals nothing.

Tests
# head 2ff85dadc5
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/serve-host-crypto-provider.test.ts src/commands/serve-audit-registration.contract.test.ts test/vitest-tiers-partition.test.ts
 Test Files  3 passed (3)
      Tests  42 passed (42)

# the cli unit tier, head b308ea87 (one commit earlier)
pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2
 Test Files  1 failed | 392 passed (393)
      Tests  1 failed | 5527 passed (5528)
# the one red: serve-audit-registration.contract.test.ts, whose #9863 scan anchors on the text
# "for (const plugin of plugins)" -- the new declaration check used the same text.
# 2ff85dadc5 renames that loop; the file passes above. The commit changes nothing else a unit test reads.

pnpm --filter @objectstack/types typecheck                                                  exit 0
NODE_OPTIONS=--max-old-space-size=6144 pnpm --filter @objectstack/cli typecheck            exit 0
  check:test-typecheck: OK -- 3 file(s) / 28 error(s) / 6 pinned signature(s) held (unchanged ledger)
  (at the default 2 GB heap the test-layer tsc ran out of memory; CI's lane uses 6144)
Ablation: removing the registry read turns the first test red

Via scripts/ablation-replace.mjs on packages/cli/src/commands/serve.ts (the test imports serve.ts from source):

anchor   "read: () => ctx.getServices().get(CRYPTO_PROVIDER_SERVICE),"  x1 -> x0
replace  "read: () => undefined,"  x0 -> x1
blob     4e50bae4d07d -> fb89a43cd489
 FAIL  |integration| src/commands/serve-host-crypto-provider.test.ts > a provider a plugin registers during init seals every secret > seals secret-field writes, datasource credentials and encrypted settings, and never builds the local one
Error: the local fallback was built although a provider is registered
      Tests  1 failed | 15 skipped (16)
restored: blob == HEAD (4e50bae4d07d) and `git diff HEAD` is empty
Gates
# head 2ff85dadc5 -- every family dispatch-gates derives (62), plus pnpm lint
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran gates.txt
  Run reconciliation -- 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN.
  62 x exit 0
pnpm lint                                                                                   exit 0

# repaired on the way (both in 2ff85dadc5):
#   check:where-matcher  -- the new test's sys_secret double read a $-key as a field name; it now refuses it
#   (unit tier)          -- the #9863 anchor above
# exit 3 before a whole-tree build (no dist), green after it:
#   check:i18n-coverage, check:dual-build-cjs-loads, check:dts-closure

Generated by Claude Code

…sters

`os serve` now reads a provider a plugin registered as the kernel service
`crypto-provider` (CRYPTO_PROVIDER_SERVICE, @objectstack/types) during its
init, and hands that one instance to the data engine, the datasource secret
binder and the default settings service. Without a registration it keeps its
own LocalCryptoProvider, from the same key source as before. Two declaring
plugins refuse at boot, both named; a registration after init, or a replaced
one, refuses too.

Claude-Session: https://claude.ai/code/session_016WV4czs7cUFAphQEUFSrcz
Co-authored-by: Claude <noreply@anthropic.com>
…ators in the secret-rows double

`refuseTwoCryptoProviderPlugins` iterated with the same text as the stack
`plugins` loop that the #9863 audit-registration scan anchors on, so the scan
could no longer name one place. Its loop now reads `candidate of composed`.

The new test's in-memory `sys_secret` double matched by scalar equality and
read a `$`-key as a field name; it now refuses a combinator or operator,
as check:where-matcher requires.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czs7cUFAphQEUFSrcz
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/types, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (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.

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

  • content/docs/data-modeling/external-datasources.mdx (via ICryptoProvider (symbol, a top-level type))
  • content/docs/data-modeling/validation-rules.mdx (via ICryptoProvider (symbol, a top-level type))
  • content/docs/kernel/cluster.mdx (via getKernel (symbol, a field of interface HostCryptoProviderContext), getServices (symbol, a method of interface HostCryptoProviderContext))
  • content/docs/plugins/anatomy.mdx (via getKernel (symbol, a field of interface HostCryptoProviderContext), getServices (symbol, a method of interface HostCryptoProviderContext))
  • content/docs/protocol/kernel/config-resolution.mdx (via ICryptoProvider (symbol, a top-level type), rotateKey (literal, a string literal in CRYPTO_PROVIDER_MEMBERS))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via ICryptoProvider (symbol, a top-level type))
  • content/docs/releases/v17/17-7.mdx (via ICryptoProvider (symbol, a top-level type), keyedDigest (literal, a string literal in CRYPTO_PROVIDER_MEMBERS), rotateKey (literal, a string literal in CRYPTO_PROVIDER_MEMBERS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (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, 47 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 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; 104 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 — 41 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 c77dd17f2671f35e8f194ca1e63d8cc75bfb861a → packageMentionDocs.

Which tree this was computed on

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

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

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

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 2ff85dadc556ebc7e2a8b68ebc051be4ff5d5778
Local-runs: probe — node scripts/check-test-source-alias.mjs run once on a detached worktree of the head, to answer whether the new test file trips the main gate; it ran the head's own copy of the script, which predates main's source-reach half (acd190b, #30969), so it answered only the alias half (OK) and not the half CI is red on
Scope: packages/cli/src/commands/serve.ts, packages/cli/src/commands/serve-host-crypto-provider.test.ts, packages/types/src/crypto-provider-service.ts, packages/types/src/index.ts, .changeset/30948-host-crypto-provider.md + packages/core/src/kernel.ts (use, bootstrap phases, registerService, context), packages/core/src/kernel-base.ts (trigger propagation), packages/services/service-settings/src/settings-service-plugin.ts (options, start), packages/services/service-settings/src/local-crypto-provider.ts (deferredLocalCryptoProvider, settleKeyFileChannel), packages/core/src/capability-composition.ts (resolveCapabilityArgument), packages/cli/vitest-tiers.ts, scripts/check-test-source-alias.mjs (head and main copies), packages/cli/package.json (exports)

Face: the Clause-②: yes (widening) declaration alone. check-governed-merges.mjs --pr 30993: 0 of 5 paths hit the governed register; no packages/spec/src/** file; record-recognisers.mjs --paths says the paths decide no face and the changeset's Clause-②: line decides, so --face declaration ⇒ TIER_DEFAULT. No ④.

Inputs read: card #30948 body; triage 6686459323; claim 6687208082 and its full re-post 6687449788 (file surface, ⛔ list, premise, CRYPTO_PROVIDER_SERVICE in packages/types); the card thread's record of the seat's three rulings (constant in @objectstack/types; two registrations refused without extending kernel.ts; the settings shared-instance stated in the PR body); os-dev report 6689416308; the dispatch order; PR #30993 body and file list; the head's check-runs and the red job's log.

① Derived judgments

Scope and claim surface.

  • Confirmed: the five changed paths are exactly the claim's surface (serve.ts; its test under packages/cli/; the packages/types constant and its index.ts export; one changeset). No packages/spec/**, no stack-config knob, no spec key, LocalCryptoProvider untouched, no ui/**, docs/adr/**, skills/**, .claude/**. The service name does not enter CoreServiceName; the registered value's type is the unchanged ICryptoProvider contract. In claim.
  • Confirmed: packages/types/src/crypto-provider-service.ts:38 exports CRYPTO_PROVIDER_SERVICE = 'crypto-provider', beside the DATASOURCE_SECRET_RESOLVER_SERVICE precedent (packages/types/src/datasource-secret-resolver.ts:47); packages/types/src/index.ts:110 re-exports it. One accepted-set addition on @objectstack/types.
  • Confirmed: serve.ts is not an exports entry of @objectstack/cli (packages/cli/package.json exports ., ./console, ./hook-body only), so createHostCryptoProvider, HostCryptoProviderRefusal, refuseTwoCryptoProviderPlugins, HostCryptoProvider, HostCryptoProviderSource are internal, not accepted-set.

The registered provider is read after plugin init and before any secret write.

  • Confirmed by serve.ts:6339-6349: the host plugin's init records a registry reader (ctx.getServices().get(CRYPTO_PROVIDER_SERVICE)) and an initDone that reads the kernel state live; resolve() (serve.ts:6288-6302) refuses with HostCryptoProviderRefusal while the kernel is idle/initializing and chooses once, at first use or at the host's kernel:ready hook. packages/core/src/kernel.ts:537,569: the kernel is initializing through Phase 1 and running before Phase 2 starts, so "every init has run" is exactly what initDone reads. kernel.ts:326 and kernel-base.ts:140 both supply getKernel; kernel.ts:306 supplies hook; kernel.ts:319-321 getServices() returns a fresh copy each call, so the read is live and identity-stable.
  • Confirmed: no LocalCryptoProvider is built anywhere in serve.ts any more (the test's source pin at serve-host-crypto-provider.test.ts:552, and grep on the head: the only remaining mentions are comments and strings). The fallback is the one deferred instance (serve.ts:5114-5115), built only inside local() when the registry holds nothing.

One instance to the three consumers.

  • Confirmed: the data engine gets hostCrypto.provider at serve.ts:5691 after confirm() at :5690; the datasource secret binder gets it at :5380; the default SettingsServicePlugin gets it through its existing cryptoProvider option at :5231-5234. resolveCapabilityArgument returns undefined for settings (capability-composition.ts:243), so the spread yields { cryptoProvider }; SettingsServicePlugin's constructor (settings-service-plugin.ts:200-214) spreads opts and applies the same ?? defaults as new Ctor(), so nothing else about the default settings service changes. settings-service-plugin.ts:383 reads this.opts.cryptoProvider ?? new LocalCryptoProvider(), so the option is honoured.
  • Confirmed: the settings plugin's key-file settlement (settings-service-plugin.ts:298, settleKeyFileChannel) runs in its start() regardless of the option, before any consumer of a kernel decrypts; the deferred local provider reads the env at build time (local-crypto-provider.ts:1325-1329), so the no-registration fallback still reads the settled key file.

The deferred-provider path.

  • Confirmed: the binder is composed before plugins initialize and holds the host provider, which chooses nothing until Phase 1 ends. A credential bound from a kernel:ready hook (the datasource admin's rehydration window) resolves to the registered provider, never the local one (test serve-host-crypto-provider.test.ts:438-460). A seal or open attempted during init is refused and records nothing (test :462-489, both plugin orders). So no write can be sealed by LocalCryptoProvider before the swap, and nothing is sealed by a provider the boot has not chosen.
  • Confirmed: on main the binder's deferred local provider would have built at any first use, phase regardless; on the branch a use during init refuses. No shipped consumer seals or opens during init (the binder's only holder rehydrates at kernel:ready; the settings service seals on set(); the engine is wired after start), so this refusal takes nothing away from a consumer that exists today. Noted in ③.

Fallback only when none registered; production refusal unchanged on that path.

  • Confirmed: local() is called only when the registry read is undefined (serve.ts:6298-6300); the deferred instance throws the constructor's "Refusing to start in production" error from every call. It is thrown either at the host's kernel:ready hook (serve.ts:6347-6350) or at the first earlier use; kernel-base.ts:127-128 dispatches hooks propagating, and kernel.ts:659-671 rolls back started plugins and rethrows, so the boot stops inside runtime.start() exactly as it did when the settings service's start() built the eager instance on main. Serve's post-start catch keeps the message-substring branch and adds HostCryptoProviderRefusal (serve.ts:5704). The message text is unchanged (the test at :346-359 pins the same keySource as new LocalCryptoProvider()).
  • Timing moved from Phase 2 (settings start()) to Phase 3 (kernel:ready) for a production boot without a stable key. Between them only other plugins' start() run; the HTTP socket opens at kernel:listening, after kernel:ready, so nothing is served under an unstable key. Noted in ③, not a must-fix.

Two registrations refuse at boot, naming both.

  • Confirmed: refuseTwoCryptoProviderPlugins (serve.ts:6368-6387) is called at :5650, before await runtime.start() at :5653, over the stack-mounted instances (:4714, :4750) and the resolver-mounted providers, deduplicated by name the way the kernel's own overwrite-by-name works; the refusal names every declaring plugin and the service, never a value. kernel.use does not initialize (kernel.ts:348-355; Phase 1 is in bootstrap), so "before either initializes" holds. An undeclared second registerService is refused by the kernel (kernel.ts:383-386), naming the service; test :426-434 pins it. Installed plugins (registerInstalledPlugins, :4767) are outside the declaration check and inside the kernel's; the PR says so.

No key material in snapshots or logs.

  • Confirmed: every refusal string (serve.ts:6280-6283, :6291-6294, :6307-6314, :6381-6384) carries the service name, plugin names or missing member names only. The dev diagnostic (:5695-5697) prints the service name. The test asserts every refusal against both test keys' hex and base64 (serve-host-crypto-provider.test.ts:79-83, used at :415,483,536). The only snapshot-like assertions are source pins on serve.ts text, not values.

The settings shared-instance change (narrowing?).

  • Confirmed not a narrowing. On main the default settings service built its own new LocalCryptoProvider() in start() after settleKeyFileChannel; the engine's and binder's instance was the deferred one, built after the same settlement; both read OS_SECRET_KEY or the settled key-file channel, i.e. one key. A settings store sealed earlier opens under the shared instance, and the other way round (test :361-384, both directions; :386-400 for a secret field). No reader had a second key to lose. The seat's ruling asked for this to be stated in the PR body; it is, in "No registration".

Tests.

  • Confirmed: registered provider seals secret-field writes, datasource credentials and encrypted settings through one instance, and the local fallback is never built (NO_LOCAL throws; :293-333); the binder, the settings service and the engine hold host.provider (:298,300,310). No registration: same key source and cross-open (:336-401). Two registrations: declared (:404-416), one plugin or one composed twice not refused (:418-424), undeclared refused by the kernel (:426-434). Deferred path (:437-538). Source pins on run() (:540-562).
  • Ablation read: replacing read: () => ctx.getServices().get(CRYPTO_PROVIDER_SERVICE) with () => undefined makes resolve() take the local arm; the first test's local is NO_LOCAL, which throws inside the host's kernel:ready hook; kernel-base.ts:127 propagates it and kernel.ts:671 rethrows, so await boot(...) rejects with "the local fallback was built although a provider is registered" — the red the dev reported. Removing the refuseTwoCryptoProviderPlugins call reds :556-561; removing the settings cryptoProvider handover reds :549; a second setCryptoProvider or a new LocalCryptoProvider( reds :552-553. The ablations would go red.
  • Confirmed: the file is integration tier by vitest-tiers.ts (new ObjectQL( fires objectQLCtor), as the dev says.

Must-fix.

  • M1 — packages/cli/src/commands/serve-host-crypto-provider.test.ts:66 (the ./serve.js value import) trips main's Test-source alias gate (source-reach half). CI check-run Lint · CI, release & engine gates on the head is red at step 45 "Test-source alias gate"; the lane verdict reads "64 gate(s): 63 passed, 1 red … Red: Test-source alias gate". Eight findings, all against this one file: it reaches import('@objectstack/metadata-protocol') (serve.ts:3290), import('@objectstack/metadata') (serve.ts:3601, 3659), import('@objectstack/plugin-hono-server') (serve.ts:3859), import('@objectstack/rest') (serve.ts:4884, schema-migration-plugins.ts:1994), import('@objectstack/driver-turso') (storage-driver.ts:700, schema-migrate.ts:814), import('@objectstack/driver-sql') (schema-migrate.ts:1697, 1703, 1830), import('@objectstack/platform-objects/plugin') (schema-migration-plugins.ts:1729, plugin-trust-store.ts:25), import('@objectstack/plugin-security') (schema-migration-plugins.ts:1979, 2133) — each a dynamic import inside a function body in the source under test, with no module-scope load in the reach, and each a specifier @objectstack/cli resolves through dist/ (KNOWN_UNALIASED_TEST_IMPORTS). The half judges only test files a change adds or edits, which is why the sibling tests that already import serve.ts are not flagged. The gate's own remedy: add module-top side-effect imports to this test file for those eight specifiers (import '@objectstack/metadata-protocol'; etc.); the source keeps its dynamic imports. Adding @objectstack/driver-* value imports does not change the file's tier (already integration). Base signature check: Lint & Repo Gates is green on the merge-base 1b3e22f0 and on main 000a4c0d, so this is not a same-signature base red; the half landed on main in acd190b (ci(test-source-alias): follow a changed test into the source it loads, and red a cold literal import() paid there #30969) after the merge-base, and CI runs the merge commit. The fix is in a file inside the claim's surface; the next head owes a delta review of that import block only.

Should-fix.

  • S1 — serve.ts:6342-6345: initDone returns true for any state other than idle/initializing, so a kernel without getKernel (the context type marks it optional) would refuse every seal until the host's own kernel:ready hook sets ready. Both shipped kernels supply getKernel (kernel.ts:326, kernel-base.ts:140), so this is dormant; a one-line comment naming the assumption is enough.
  • S2 — the PR body's "Acceptance notes" list the gaps correctly (one-shot secret commands build their own local provider; installed plugins outside the declaration check; an app-composed SettingsServicePlugin not handed the host provider; a declaring plugin that never registers falls back silently). The first is a real gap for a KMS-keyed deployment (os secret rewrap cannot open what serve sealed) and should be a card, not a note; the seat carries it (the report names "the seat / cloud#3573" as carrier).

② Semver level

  • @objectstack/cli: patch, @objectstack/types: patch. Right. Metadata-protocol: none is right: nothing an AI author writes changes (no spec key, no stack-config knob, no metadata type), so the level is patch on both packages the diff moves. The changeset carries both declaration lines at line start, the widening arm, no BREAKING banner, and states the one no-registration difference (the shared settings instance). It names what a plugin does (registerService in init(), providesServices) and every refusal, with no key value.
  • Clause-②: yes (widening). Right. The accepted set widens by one published constant (CRYPTO_PROVIDER_SERVICE on @objectstack/types) and by one host behaviour (os serve honours a provider a plugin registered during init). Nothing a consumer could rely on today is taken away: the service name had no reader on main, so no composition registering it could have worked; the new refusals (two declaring plugins, a late or replaced registration, a non-provider value, a seal during init) all sit on paths no shipped consumer uses. Not (narrowing).

③ Boundary flags

  • Author: PR user.login is objectstack-fleet[bot] (relay); no out-of-relay author to name.
  • The dev's three deviations, answered: (1) the settings shared instance — accepted above as not a narrowing, stated in the PR body and pinned both ways; (2) packages/types in the surface — added by the seat's re-post 6687449788, in claim; (3) NODE_OPTIONS heap for the local test-layer typecheck and the unused harness trailer — no code effect.
  • open_questions: none in the report. security_findings: none; this review found none. Key material: none printed anywhere.
  • Timing note, not a flag: a production boot without a stable key now refuses at kernel:ready (Phase 3) instead of at the settings service's start() (Phase 2); same message, same runtime.start() rejection, socket not yet open. The PR body says "still fires at kernel:ready", which is accurate.
  • Narrowing check on the init-time refusal: a seal or open through the binder during plugin init now refuses where main would have built the local provider; no shipped consumer does this, and doing it would have read the key file before the boot chose it. Not a narrowing of a declared capability.
  • Out-of-scope findings the dev named (one-shot secret commands; installed plugins outside the declaration check; app-composed settings plugin; declare-but-never-register) are read and agreed; the first should become a card (S2).
  • The head is red on one gate (M1). Any auto-merge on this head would be disarmed by rule; none is armed (draft PR).

Check-runs on the head (read at 16:17Z, API)

60 check-runs: 23 success, 9 skipped, 2 failure, 26 in_progress. Red: Lint · CI, release & engine gates (job 127999666346, step 68 "Lane verdict" after step 45 "Test-source alias gate" failed under continue-on-error) and its aggregator Lint & Repo Gates. Signature: step "Test-source alias gate", first error line "✗ packages/cli/src/commands/serve-host-crypto-provider.test.ts:66: reaches import('@objectstack/metadata-protocol') in the source under test, at packages/cli/src/commands/serve.ts:3290". Merge-base 1b3e22f0 and main 000a4c0d are green on Lint & Repo Gates, so the red is the PR's own. Test Core, Test UI, Type Check · workspace and Type Check UI were still running at the read.

Implemented-by: claude/project-thread-bimizv
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: FAIL

The host crypto provider test imports `serve.ts`, which reaches eight
packages through function-body `import()` calls. check-test-source-alias's
source-reach half (#30969) asks a changed test file to pay those first loads
during collection, so the file now imports them for their side effects at
module top, as the other serve-reaching cli tests do (#10126). serve.ts keeps
its dynamic imports.

Also notes in serve.ts that the host provider's init-done reading assumes
`ctx.getKernel`.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czs7cUFAphQEUFSrcz
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 6, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review · delta

Served-tier: TIER_DEFAULT
Head-sha: fbc8206285c9f7877adbe479cd7bb525a603efd3
Local-runs: probe — node scripts/check-test-source-alias.mjs --changed-from origin/main run once on a detached worktree of the head (no install, no build; the script needs only sibling scripts and node builtins) to answer whether the eight module-top imports clear main's source-reach half; run a second time on the pre-fix merge commit 816331a1 to show the same probe reds there with exactly the eight specifiers, so its green on the head is a reading, not a run that judged nothing. The worktree was removed afterwards.
Scope: packages/cli/src/commands/serve-host-crypto-provider.test.ts, packages/cli/src/commands/serve.ts + packages/cli/package.json (dependencies / devDependencies), scripts/check-test-source-alias.mjs at main c77dd17f (header §"The source reach", IMPORT_PATTERNS, moduleLoadSites, reachEdgesOf, sourceReachFindings, changedFilesSinceMergeBase), packages/cli/vitest-tiers.ts (tier predicate), packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts (the #10126 precedent block), the eight packages' manifests and entry modules (packages/metadata-protocol/src/index.ts, packages/metadata/src/index.ts, packages/plugins/plugin-hono-server/src/index.ts, packages/rest/src/index.ts, packages/drivers/driver-turso/src/index.ts, packages/drivers/driver-sql/src/index.ts, packages/platform-objects/src/plugin.ts, packages/plugins/plugin-security/src/index.ts) plus a grep of each package's src/ for import-time crypto or global writes, PR #30993 metadata and file list, check-runs on the head.

Delta over record 6689701608 (head 2ff85dad…). The new head is two commits on the old one: 816331a1, a merge of origin/main at c77dd17f (which carries the source-reach half of the gate, acd190b), and fbc82062, the fix. Nothing else moved; the PR's file list is the same five paths (+933/−60 against main). This record judges only the two commits; everything the full record confirmed about the provider selection, the three consumers, the refusals and the changeset stands unchanged, because the fix commit touches no code.

  • Merge 816331a1: git merge-tree --write-tree 2ff85dad c77dd17f yields tree e9eeb06b…, byte-identical to the merge commit's tree. No conflict, no hand edit. git diff c77dd17f 816331a1 is exactly the PR's five files.
  • Fix fbc82062: two files, +13/−0. serve-host-crypto-provider.test.ts gains, at module top directly under the ./serve.js import (lines 68–77), a [#10126] comment and eight bare side-effect imports: @objectstack/metadata-protocol, @objectstack/metadata, @objectstack/plugin-hono-server, @objectstack/rest, @objectstack/driver-turso, @objectstack/driver-sql, @objectstack/platform-objects/plugin, @objectstack/plugin-security — the eight the gate named, spelled exactly as the findings spelled them (the /plugin subpath included). serve.ts gains two comment lines above initDone (6342–6343) and no code: the dynamic import() sites the gate reported are untouched. This is the shape the precedent file organization-ownership-apply.served-catalog.integration.test.ts:57-62 uses.
  • Dependencies: all eight are declared on @objectstack/cli at the head — seven under dependencies (workspace:*), @objectstack/driver-turso under devDependencies (workspace:*); @objectstack/platform-objects exports ./plugin (dist/plugin.mjs / .js). A test file may import a devDependency, so the import resolves in every run that builds first (turbo test dependsOn ^build, which Test Core does).
  • Gate logic, read on main's copy: sourceReachFindings walks the test's collection-time reach into same-package files and reports a function-body import() of a measured specifier unless warm.has(spec), where warm starts as the test file's own moduleScope. moduleLoadSites adds every non-dynamic match of IMPORT_PATTERNS to moduleScope, and that pattern's fifth alternative (import\s+['"]…['"]) matches a bare side-effect import — the check-test-source-alias loses a bare side-effect import 'x'; whenever any later import … from … follows it, so the clocked-window rule reports a file that already applied the fix it prescribes #12555 note in the header says precisely that this shape must be read, and isTypeOnlyClause does not apply (no clause). So each of the eight findings is suppressed by its line. The test file has no vi.mock, so no mock is credited; the half still judges the file (it is added relative to the merge base c77dd17f).
  • Probe confirms the reading: on the head, check-test-source-alias OK — … the source reach judged 1 test file(s) changed since c77dd17f26, exit 0; on 816331a1 (same script, same test file minus the block) exit 1 with sixteen lines naming serve-host-crypto-provider.test.ts and exactly the eight specifiers above. CI agrees: Lint · CI, release & engine gates (run 128013911913) completed success at 16:37:04Z, the lane that was red on 2ff85dad at step 45.
  • Test semantics: the eight modules declare sideEffects: false; their entry modules are re-export barrels, class and const declarations, and driver manifests whose onEnable is a function value, nothing executed at load. A grep of each package's src/ finds no CRYPTO_PROVIDER_SERVICE, LocalCryptoProvider, setCryptoProvider, 'crypto-provider', globalThis.x = or process.env.X = write, and no column-0 call statement outside tests. The provider the test observes is still chosen by the kernel each case builds (registerService in the test's own plugins, the deferred local arm, NO_LOCAL), after these loads have long finished; the ./serve.js import already preceded them and loads the same @objectstack/core/service-settings graph the test imports itself. The file's tier is unchanged: vitest-tiers.ts already classed it integration on new ObjectQL(, and a @objectstack/driver-* value import is the same predicate's other arm.
  • M1 (full record): closed by the test-file block above. S1 (full record): closed by the two-line comment, which states the ctx.getKernel assumption and its consequence (nothing chosen before the host's own kernel:ready hook) and matches the code it sits above (ctx.getKernel?.()?.getState?.()). S2 (one-shot secret commands as a card) is the seat's, not this head's.

① Derived judgments

  • The fix adds no behaviour and removes none: eight module-top loads in one integration-tier test file and a comment. The accepted set, the refusals, the three-consumer handover and the changeset are byte-identical to the head the full record read; the merge brought nothing of the PR's into contact with a changed main file beyond the gate script itself, which is what the fix answers.
  • The remedy is the one the gate prescribes and the one the sibling test already uses; serve.ts keeps its dynamic imports, so the host pays nothing extra at boot.
  • No must-fix. No should-fix.

② Semver level

  • Unchanged from the full record: @objectstack/cli: patch, @objectstack/types: patch, Clause-②: yes (widening), Metadata-protocol: none. A test-only import block and a comment move no package's level.

③ Boundary flags

  • Author of both new commits: Claude <noreply@anthropic.com>, trailer pair present and model-free; PR author objectstack-fleet[bot]; draft, no auto-merge armed. No governed path.
  • Observation, outside this delta and pre-existing on main: serve.ts reaches @objectstack/driver-turso at run time (storage-driver.ts, schema-migrate.ts) while @objectstack/cli declares it only as a devDependency. Not this PR's change and not a finding against it; noted for whoever owns the cli manifest.
  • Check-runs on the head (read 16:38Z, API): 59 check-runs — 18 success, 9 skipped, 32 in_progress, 0 failure. Lint · CI, release & engine gates is green; every completed Lint · lane is green (Lint · scoped · query-options erasure still running). Test Core (8 shards), Test UI (16), Build Core, Build & E2E UI, Live E2E UI and the four Type Check lanes were still running at the read, so "every check green" is not yet a fact of this head; nothing red.

Implemented-by: claude/project-thread-bimizv
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT · 2026-11-06T16:41Z · #30948 (serve uses a registered crypto provider)

Head-sha: fbc8206

  • Contract review: TIER_DEFAULT (exact: no published schema, no governed path; the face is the Clause-② declaration). Full record 6689701608 on 2ff85dad found the contract faces clean and raised one must-fix in the test file: the Test-source alias gate. Delta record 6690090060 on this head: PASS, no must-fix. The merge 816331a1 reproduces byte-exact. The fix adds eight module-top side-effect imports, all declared dependencies, and none has an import-time side effect touching crypto or the kernel. The initDone comment (S1) is in.
  • Inside claim 6687208082 with the seat's three rulings: the constant lives in @objectstack/types; two registrations are refused without extending kernel.ts; the settings shared-instance change is stated in the body and pinned both ways. Five paths, no packages/spec change.
  • Read by the seat:
    • The registered provider is read after plugin init and chosen once. The same instance goes to the data engine, the datasource binder and the default SettingsServicePlugin.
    • Nothing can be sealed by LocalCryptoProvider before the choice is made.
    • The fallback applies only when nothing is registered, and its production refusal text is unchanged.
    • Two declaring plugins are refused at boot, with both named. No key material appears in any refusal, log or snapshot.
  • Follow-up filed: cli: one-shot secret paths (os secret rewrap, one-shot settings) still build their own LocalCryptoProvider, so they cannot open what os serve sealed with a registered provider #31007 (the one-shot os secret rewrap and one-shot settings paths still build their own LocalCryptoProvider).
  • Gates: Lint · CI, release & engine gates is green on this head (it was the red lane); none red at the time of this record, with the long lanes still running. Locally: dev report 6689416308 plus the delta readings in the thread. Auto-merge waits for the rest.
  • Changesets: @objectstack/cli patch, @objectstack/types patch, Metadata-protocol: none, Clause-②: yes (widening): right.
  • Landing: no governed path, under 3000 lines. Ready, then auto-merge, through the relay. Fixes #30948.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42258941523 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Dogfood Regression Gate (3/3) — 失败步骤: Boot example apps and exercise real user flows

    @objectstack/dogfood:test:  FAIL   isolated  test/admin-platform-admin-standing.dogfood.test.ts > #9482: what an ObjectStack platform admin gets from every /admin/ route > no /admin/ route refuses the
      ↳ 失败原因: @objectstack/dogfood:test: AssertionError: the stack serves /admin/ route(s) this file neither admits, refuses-by-design, nor classifies as answering for a non-authorization reason. A new admin route 
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 18 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants