Skip to content

fix(service-settings): judge the whole fetched page when two data-key files differ, and report a mixed-key database - #30232

Queued
objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/project-thread-65uubl
Queued

objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/project-thread-65uubl

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6CahkbD22YHPM8hMrA8

Fixes #30091

Clause-②: no — an internal boot heuristic and its notice text. The shapes it changes (StoredSecretReader answers the page, KeyFileChoice gains the mixed reason and a tally, DataKeyOpensNothingError gains judged) were added by #30042, whose changeset is still pending, so they first ship together in the same release; no released export changes, and no new error code is added.
Metadata-protocol: none

维护者速读

  • 改了什么:两个数据密钥文件不一致时,启动试开不再只看最新一条 sys_secret,而是对已取回的最新一页(最多 10 条)逐条用两把钥匙试开,用打开条数更多的那把(平局取能打开最新一条的那把);两把各开一部分时,按「混合密钥」打一条启动错误,点名两个文件、各开几条和修法;最新一条坏了但较早的行能开时,照常选出钥匙,不再在生产拒启。提示文案改为只说实测到的(「打开最新 3 条中的 2 条」),不再说「打开本库全部已存密钥」。另修三个评审小问题,并在运维文档加一段两文件规则。
  • 为什么改:fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production #30087 的试开只判一行。库里两把钥匙的密文并存时,没选中那把下的密钥照样读成「未设置」且无提示;最新一行损坏时,生产启动会拿钥匙问题拒启,即使较早的行足以定夺。
  • 风险与回滚:只改启动时选哪个密钥文件的判法与提示,不改任何密文、不重新封装;生产拒启只剩「整页没有一行能被任一把打开」这一种。混合密钥选择提示而非拒启(理由见 Acceptance notes)。回滚即撤本 PR,回到只判最新一行。
  • 先看:packages/services/service-settings/src/local-crypto-provider.ts 的 chooseKeyFileByTrialOpen(平局与 mixed 分支)与 scoreKeyOverPage;同文件 storedSecretReaderOverEngine(现返回整页);content/docs/deployment/backup-restore.mdx 新增的那一段。
  • 两分钟验证:pnpm --filter @objectstack/service-settings exec vitest run src/local-crypto-provider.test.ts,预期 74/74 通过,其中 #30091 — the trial scores both keys over the whole page 一组 8 条。
  • 需要你做的:无,席位落地。

What changes

  • The trial scores the page, not one row. StoredSecretReader now answers the newest page, newest first (empty = no stored secret); storedSecretReaderOverEngine returns every known-marker row of the page it already fetched (limit: 10, unchanged) instead of the first. chooseKeyFileByTrialOpen opens each row under both keys (scoreKeyOverPage, each scope as before) and counts.
  • The verdicts. One key opens rows and the other none: opens, that key's file. Both keys open rows: the new mixed reason, the file whose key opens more of them (a tie goes to the key that opens the newest of them, the key sealed with last), with an error notice naming both paths, both counts, the consequence and the fix, in every posture. No row opens under either key: unchanged (opens-nothing; production throws DataKeyOpensNothingError, which now records and prints how many rows it judged). A row that opens under neither key no longer decides anything while another row opens: a corrupt newest row with an older row under one key settles on that key.
  • KeyFileChoice.tally (judged, chosen, other) records what was measured whenever rows were judged.
  • Notice wording. The two opens notices said the chosen file "opens this database's stored secrets"; they now say what was measured ("opens the 3 newest stored secrets", "opens 2 of the 3 newest stored secrets", plus how many open under neither). The refusal reads "neither data key opens any of the N newest stored secrets".
  • Review nits from fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production #30087. serve.ts: the datasource-binder comment and catch no longer claim a provider-construction error lands there (the deferred provider constructs nothing at that point; a key problem surfaces at the secret-field wiring's resolve(), which rethrows the production refusal). service-settings README: the file tier names OS_HOME / ~ expansion, the project's legacy file and the two-file trial. one-shot-settings.ts: JSDoc that resolveExistingDataKey sets or deletes both channels on opts.env, which defaults to the real process.env.
  • Operator docs. One paragraph, "Two key files on one host", in content/docs/deployment/backup-restore.mdx right after the existing "A database backup without OS_SECRET_KEY is an incomplete backup" callout, the one page that already tells operators the home dir holds the dev-minted key: the two files, what the boot does when they differ, the split and refusal cases, and the remedy. content/docs/ is devx-owned; this PR adds that one paragraph and nothing else there.
  • Changeset .changeset/30091-data-key-trial-page.md (patch), with a "Supersedes, in the same release" paragraph for cli: a boot whose data key cannot open the database's sys_secret rows still serves every secret as unset — os start / os dev should pick the key that opens them, or refuse loudly; one-shot commands read only the canonical key file #30042's pending note, which quotes the old refusal text.

Tests

  • service-settings local-crypto-provider.test.ts (+8, describe #30091 — the trial scores both keys over the whole page, not its newest row): a split page is mixed on the majority key with an error naming both paths, in production and in development (no refusal); a tie goes to the key that opens the newest row, both orders; a corrupt newest row with older rows under one key settles on that key in production; one key opening every row is opens with its tally; a page where nothing opens keeps the production refusal and records judged; settleKeyFileChannel on a mixed page points the channel at the majority file and prints one console.error, no warning. The reader-over-engine test now pins the whole page in order, the unknown marker still skipped. Existing trial tests moved to the page reader unchanged in what they assert.
  • cli internal-crypto-key-file-channel.test.ts and one-shot-settings.pin.test.ts: their stub readers answer a one-row page (the type change), assertions unchanged.

Acceptance notes

  • Mixed-key is an error notice, not a production refusal. A split database is measured, not guessed: the chosen key opens real rows, and refusing would leave the operator no running server through which to save the minority's secrets again (no tool re-seals from a second key). The refusal posture from fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production #30087 (production refuses when no key opens anything) is unchanged. If the maintainer wants a split database refused in production as well, that is a one-branch change in chooseKeyFileByTrialOpen plus a (narrowing) declaration; it is raised in the dev report.
  • The page is ten rows. A database whose split lies only beyond its newest ten rows is still judged by those ten; the notices say "of the N newest", so they claim no more than that.
  • No new error class or code: mixed is a KeyFileChoice reason, and the refusal stays DataKeyOpensNothingError.

Verification

All readings on head 26eb6119 (branch merged with main at ff12aed3) unless named.

Build, typecheck, tests
  • Build: pnpm --filter '@objectstack/cli^...' --filter @objectstack/service-settings build, VERDICT command-exit 0; then @objectstack/cli build, exit 0.
  • Typecheck: @objectstack/service-settings 0 (its tsconfig includes src/**, tests included), @objectstack/cli 0 (with check:test-typecheck).
  • service-settings: 1159/1159 (67 files), on 26eb6119.
  • cli unit (--project unit): 380/382 files on the first run, the two red files (published-subpath-console.pin, published-subpath-hook-body.pin) refusing because packages/cli/dist was not yet built; after the cli build both pass, 29/29. On 26eb6119 the two cli files this diff edits: 23/23. The cli integration layer is declared to CI (no integration file or spawn entry is touched).
Ablations (each through scripts/ablation-replace.mjs; restored blob == HEAD, `git diff HEAD` empty)

The pins live in service-settings, whose tests import ./local-crypto-provider.js from src, so no dist leg applies.

  • A, the trial judges only the newest row (page = (await opts.readStoredSecret()).slice(0, 1)): 8 red, the whole new #30091 group.
  • B, the production refusal removed (the throw new DataKeyOpensNothingError(...) line deleted): 4 red across local-crypto-provider.test.ts and settings-engine-bind-window.test.ts (fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production #30087's refusal pin and its channels-left-as-they-were pin, the new page refusal pin, and the real-kernel bind-window refusal).
  • C, a split page reported as a quiet opens (if (rejected.opens === 0) forced true): 5 red (mixed ×2, tie ×2, settle-mixed).
Gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 95 derived families, the same list on the pre-merge head and on 26eb6119. Each run with its exit code recorded before any pipe; on 26eb6119 all 95 exit 0, and --ran answers: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass three exited 3 for environment prerequisites (check:skill-examples and check:dual-build-cjs-loads before client-react and seven other dists existed, check:i18n-coverage before connector-slack's); after building those packages all three exited 0.
  • pnpm lint: exit 0 on 26eb6119.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic: packages/spec untouched.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 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: n/a.
  • 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 Chinese by the Communication rule).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CahkbD22YHPM8hMrA8

… files differ, and report a mixed-key database

The boot trial scored only the newest known-marker sys_secret row. It now
scores both keys over the page the reader already fetched (up to ten rows),
takes the key that opens more of them (a tie goes to the newest), reports a
page split between the keys as a boot error naming both files, and lets an
older row settle the choice when the newest row opens under neither key.
Notices say what was measured. Review nits from the trial-open PR: the stale
datasource-binder catch comment in serve.ts, the README file-tier line, and a
JSDoc note that resolveExistingDataKey writes the channels on process.env.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CahkbD22YHPM8hMrA8
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-settings, touching 9 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-settings/README.md, packages/services/service-settings/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.

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

  • content/docs/automation/webhooks.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/data-modeling/drivers.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/data-modeling/external-datasources.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/data-modeling/objects.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/data-modeling/odata-datasources.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/data-modeling/validation-rules.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/deployment/backup-restore.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/deployment/cli.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/deployment/environment-variables.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/permissions/authorization.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))

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

  • content/docs/releases/implementation-status.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/releases/v16.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/releases/v17/17-0.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/releases/v17/17-7.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))
  • content/docs/releases/v18/18-0.mdx (via sys_secret (literal, a string literal in storedSecretReaderOverEngine))

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
  • 2 changed file(s) yielded no anchor (packages/services/service-settings/README.md, packages/services/service-settings/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 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 8f05e61bb33a17a196785d7df2109cdd81ee1e97 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8f05e61bb33a17a196785d7df2109cdd81ee1e97

⚠️ 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 8f05e61bb33a17a196785d7df2109cdd81ee1e97 → 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#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

  • 改了什么: 两份数据密钥文件(家目录共享的那份和项目自己的那份)钥匙不同时,启动从「只试开最新一条密文」改成「试开最新一页(最多十条)」,取打开更多条的那把;两把各开一部分时,按多数用、打一条点名两个文件和条数的 error。
  • 为什么改: 原来只看一条:最新一条坏了,生产就整体拒启;数据库里两把钥匙混着封的密文,启动却一声不吭。
  • 风险与回滚: 两份文件钥匙相同或只有一份时行为不变;一页里两把都打不开仍按原样生产拒启。回滚即撤本 PR。
  • 先看: local-crypto-provider.ts 的 chooseKeyFileByTrialOpen 与新的 scoreKeyOverPage,再看测试里 #30091 那组。
  • 两分钟验证: pnpm --filter @objectstack/service-settings exec vitest run src/local-crypto-provider.test.ts。
  • 需要您做的: 无,席位经合并队列落地(非受管面,390 行)。
  • 席位意见: ACCEPT。开发留的问题「两把钥匙混用时生产是否也拒启」,席位按卡面裁 A(照常启动、打 error):卡写的是「报为点名两条路径的混合密钥启动错误」,是报错不是拒启;拒启是收窄,还会让运维没有一个在跑的服务去重新保存少数那部分密文。

席位核对 — ACCEPT · domain:server#2 · head 26eb6119c48ab35d966e4adfe199644779026e92

  • 形态: draft, author objectstack-fleet[bot] (relay pr_create), no assignee, no review request; 11 files, +306 −84; check-governed-merges --pr 30232: NOT governed (0 of 11 paths), 390 lines ≤ 3000. Dev write tally: 2 relay writes, mcp_calls 5 reads, 0 MCP writes.
  • 前提实测: on origin/main, storedSecretReaderOverEngine already fetched TRIAL_SAMPLE_PAGE (10) rows and returned the first known-marker one, and chooseKeyFileByTrialOpen judged that one row (claim 6647291404 Premise: holds). The diff returns the known-marker page and scores both keys over it; neither = judged − c − l is sound because two distinct AES-GCM keys cannot both open one row.
  • 逻辑核对 (seat read): both 0 ⇒ production DataKeyOpensNothingError(…, judged), else canonical + error; one side 0 ⇒ opens warn naming the measured count; both > 0 ⇒ mixed error naming both paths, both counts and the fix, in every posture. Tie-break legacyWins takes the legacy file only when it opens strictly more, or the same count with a strictly newer first row; otherwise canonical keeps it. Missing-table read still answers empty (no-stored-secret), any other failed read still not-judged.
  • 门禁 check-runs: 50 runs on the head, 38 success, 12 skipped, 0 failing. Dev: dispatch-gates 95/95 exit 0, --ran 0 NOT-MEASURED / 0 UNRUN; service-settings 1159/1159; 3 ablations (one-row slice 8 red, refusal line 4 red, mixed-as-quiet 5 red), each restored to HEAD.
  • 契约复审: not owed. Clause-②: no verified: @objectstack/service-settings@18.3.0 (npm latest; tag source) contains none of chooseKeyFileByTrialOpen / StoredSecretReader / DataKeyOpensNothingError (0 hits), so the changed shapes (cli: a boot whose data key cannot open the database's sys_secret rows still serves every secret as unset — os start / os dev should pick the key that opens them, or refuse loudly; one-shot commands read only the canonical key file #30042 via fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production #30087) first ship in this release. No packages/spec, no governed rule text, no narrowing declaration ⇒ CI + seat self-read.
  • 散文面逐句核 (TIER_DEFAULT): changeset sentences "trial-opens each of the newest stored sys_secret rows (up to ten) … on a tie the one that opens the newest", "a row that opens under neither key … no longer decides the boot", "only a page where no row opens under either key keeps the production refusal" and the Supersedes paragraph's quoted refusal text match the diff. backup-restore.mdx paragraph: canonical path (OS_HOME, else ~/.objectstack), legacy only when it alone holds a key, "uses the file whose key opens more", split ⇒ error naming both files, neither ⇒ production refuses — each matches the code. README file-tier text and the serve.ts catch comment (fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production #30087 nits) checked against the code they describe.
  • 裁决 (open question): A — keep the boot on the majority key with the error notice. Card text "report … as a mixed-key boot error that names both paths" is satisfied by an error notice; B (refuse) is a narrowing the card did not ask for. Not escalated.
  • 接受的偏差: two cli test stubs changed one line each (one-row page after the reader type change, assertions unchanged); settings-service-plugin.ts / index.ts touched for the same type and a comment; the operator paragraph lives in content/docs/deployment/backup-restore.mdx (the page that already names the dev key), one paragraph only.
  • 范围外去向: none. Skills-docs: not owed (Metadata-protocol: none).
  • 落地条款: Clause-②: no, Metadata-protocol: none, changeset patch for @objectstack/cli and @objectstack/service-settings. Ordinary queue landing by this seat: pr_ready + automerge_enable on this head.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 03:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 03:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
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

Development

Successfully merging this pull request may close these issues.

cli/service-settings: judge the whole fetched page when two data-key files differ, and fix the trial notice wording

1 participant