Skip to content

fix(settings): declare the settings ordering edge and make the pre-bind read audible - #11044

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-10250-settings-prebind-window
Aug 22, 2026
Merged

fix(settings): declare the settings ordering edge and make the pre-bind read audible#11044
os-warren merged 1 commit into
mainfrom
claude/issue-10250-settings-prebind-window

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#10250

Implements the maintainer ruling recorded on the card (2026-08-22 decision-inbox digest, 46 cards, accepted verbatim 「接受所有」): A + C + the pin expansion, shipped together.

The defect, restated with the corrected population

SettingsServicePlugin binds its data engine from a kernel:ready hook registered in its start(). Three shipped plugins read a settings namespace from a kernel:ready hook registered in theirstart():

readerreads
packages/plugins/plugin-email/src/email-plugin.ts:388getNamespace('mail') — SMTP / provider / from-address
packages/services/service-sms/src/sms-plugin.ts:189getNamespace('sms') — provider credentials, daily cost ceiling
packages/services/service-storage/src/storage-service-plugin.ts:406getNamespace('storage') — backend + credentials

Hooks fire in registration order, so whichever plugin starts first registers the earlier hook. A reader that started before the settings plugin read SettingsService's in-memory fallback — empty at boot — and received the manifest default with source: 'default' and locked: false, with no diagnostic anywhere, while the operator's saved row sat unread in sys_setting.

Nothing constrained that order. None of the three declared any dependency on com.objectstack.service.settings, so their position was pure kernel.use() order. It was correct under os serve only because the always-on slate lists settings ahead of them — and serveprepends an app's declared requires while only appending the slate, so an ordinary requires: ['email'] produced email-before-settings and bypassed it. Cloud's objectos-runtime mounts the slate from its own wiring, which is why a CLI-only repair (option D) was rejected.

The card's own population guess (kernel:ready hooks registered from init()) measured zero; the mechanism analysis was correct. Re-scoped in place per the ruling.

1 · A — declared, order-if-present

Each of the three plugins now declares:

optionalDependencies=['com.objectstack.service.settings'];

ObjectKernel.bootstrap and LiteKernel.bootstrap both iterate the sameresolvePluginOrder output for Phase 1 (init) and Phase 2 (start), so the declaration is what puts the bind ahead of the read — in any host, not just the CLI. Soft, not hard: a kernel with no settings service still boots these plugins unchanged.

2 · C — the residual is audible

SettingsService.loadRows now reports one operator-actionable warn per namespace when a read lands in a declared-but-pending bind window:

[SettingsService] Pre-bind READ of namespace 'mail': the data engine is declared but not
yet bound, so this read was answered from the in-memory fallback and the manifest defaults
— any persisted `sys_setting` row was NOT consulted. Declare optionalDependencies:
['com.objectstack.service.settings'] on the reading plugin so it starts after the settings
engine binds (earliest safe phase: kernel:bootstrapped).

Deliberately not a refusal: an in-window read of a setting that genuinely has no persisted row must answer the manifest default, and #10159's fix left reads open for exactly that reason. It is placed in loadRows rather than get() because that is the precise point at which persisted state would have been consulted — an in-window read satisfied by an OS_* override returns before reaching it, and that answer is correct.

The load-bearing half of the suite is the silence. Five populations must stay quiet, each a distinct shipped shape rather than a variation on one: a correctly-ordered boot; the plain slate order; every read after bindEngine; a kernel with no objectql at all (settleWithoutEngine); a directly constructed SettingsService; and an in-window read satisfied by an env override.

3 · The pin expansion — the boundary is derived, not bumped

The existing assertion pins PLATFORM_ALWAYS_ON_CAPABILITIES.slice(0, 6). The slate, enumerated by parsing the array (not by counting grep hits):

 0 queue 3 settings 6 sms 9 analytics
1 job 4 email 7 sharing
2 cache 5 storage 8 messaging

sms is at index 6 — exactly one past the pinned prefix, and it is a settings reader. Its correct position was held by nothing.

This is not repaired by widening the slice to 7; that is the same bug moved one position, and it would leave sharing / messaging / analytics and every future entry outside again. The new pin states the rule the prefix was always for — the spec test's own comment says it: "settings/queue/job must precede the services that bind to them during their own kernel:ready phase."

constBIND_TARGETS=['queue','job','cache','settings'];// every always-on entry that is NOT a bind target is mounted after ALL of them

An eleventh entry added tomorrow is covered the moment it lands, wherever it goes; one inserted before settings turns the pin red. Case 8 is the derivation's own positive control — the same predicate run over a hostile slate must name the violators, so a predicate that reported nothing on a real regression cannot pass.

⚠️One residue this lane could not touch. The literal slice(0, 6) assertion and its "grow the slate AFTER these six" comment live in packages/spec/src/kernel/platform-capabilities.test.ts, and this lane has zero packages/spec ownership. The derived pin therefore lands in @objectstack/cli — the only package that depends on all the providers and the runtime that actually appends the slate to an app's requires. The spec-side assertion still passes and is still true; it is now strictly weaker than the cli one. Replacing it is a one-line spec edit that carves out as its own card. No slate ordering was changed, so nothing about what a deployment boots with moves.

Proof

Resolution, argued from the files.packages/services/service-settings/src/settings-prebind-read-warning.test.ts imports its subject relatively (./settings-service.js) → resolves to sibling src, no build between edit and verdict. packages/cli/src/commands/serve-settings-ordering.pin.test.ts imports bare workspace specifiers, all of which sit in KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli'] and none of which packages/cli/vitest.config.ts aliases → resolves to dist/. Every leg of the manifest ablation therefore rebuilds and proves the marker's presence/absence in dist before its result is read. That registry is ⛔ SHRINK-ONLY, and aliasing a fourth dependency to source would pull its whole import surface into cli's resolution domain for ~137 test files (the #7378 shape) — so dist is the deliberate answer here, not an oversight.

Ablations — signatures predicted in writing before mutating.

#mutationpredictedobserved
A1neuter reportPreBindReadnoisy leg reddens, quiet legs green✅ 3 noisy red / 6 quiet green
A2drop the reporter's own guard≥3 quiet legs red⚠️2 red (legs 3, 4) — legs 1/1b/2 have the engine bound, so loadRows never reaches the reporter. Right direction, wrong magnitude, for a reason the code makes inevitable
A2bwarn on every read (the shape a warn-only suite is fooled by)6 red✅ 6 red — legs 1, 1b, 3, 4, 5 + the window-closes case. (Leg 2 survived: its mockClear() runs after the per-namespace dedupe was already consumed at boot)
A3delete the declaration from the real service-sms manifest, rebuild2 cases red naming sms, order revertssms must declare the settings ordering edge: expected [] to include … and sms: settings must resolve ahead of the reader: expected 2 to be less than 1

Restores proved byte-identical with git hash-object:

settings-service.ts restored c05abaa8005bf0b4f9cf02b313246ff35c8f3f56 == committed c05abaa8005bf0b4f9cf02b313246ff35c8f3f56
sms-plugin.ts restored 968d0e574f7afbbcb1a553780ae0083c8adc78ed == committed 968d0e574f7afbbcb1a553780ae0083c8adc78ed

A3 dist state on each leg (scripts/ablation-dist-preflight.mjs), plus the pnpm workspace symlink asserted on both — no package.json dependency edits are in this change, so the link is not a variable, but it was checked anyway:

mutate leg: ✓ marker absent from all 6 built files link: packages/cli/node_modules/@objectstack/service-sms -> ../../../services/service-sms
restore leg: ✓ marker present in 2 built files link: unchanged

Zero-hit counter-check, positive control run FIRST. The regex getService[^(]*\(\s*['"]settings['"] was proved on the three known readers before its silence was read anywhere. Full corpus (shipped source, tests/dist excluded): 13 hits — 11 real handle acquisitions plus 2 string literals inside service-settings comments. Of the ten always-on providers, exactly three acquire a settings handle: plugin-email, service-storage, service-sms; queue, job, cache, sharing, messaging, analytics return zero on an instrument already shown to see readers.

Suites (all at a1f8f0740, clean tree):

service-settings 25 files / 471 tests passed
plugin-email 27 files / 425 tests passed
service-storage 24 files / 405 tests passed
service-sms 5 files / 70 tests passed
core (plugin-order) 37 files / 887 tests passed
spec platform-capabilities.test.ts 20 tests passed (the existing slice(0,6) pin still holds)
cli, 8 targeted files (email / sms / storage / observability / this pin) 85 tests passed

Gate union derived on the final commit a1f8f0740 with a clean tree, node scripts/pm/dispatch-gates.mjs and no path arguments (13 matched + 5 convention-triggered + i18n). Every exit code captured by redirecting to a file before any pipe. Each row quotes the gate's own verdict line.

gateexitthe gate's own verdict line
check:test-source-alias0check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/…
check:cross-package-test-inputs0OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check:nul-bytes0check-nul-bytes: OK (scanned 6392 text file(s) … no raw ASCII control bytes).
check:slot-lookup0✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new…
check:type-source-resolution0check-type-source-resolution OK — 77 packages with a tsconfig.json scanned…
check:changeset-gate-self-tests0✓ check-changeset-no-major --self-test: 116 assertions…
check:objectui-changeset0✓ #7004 C7 control — … C1-C6 are about the entry regex…
check-adr-0087-registration0✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
check-changeset-no-major0✓ This diff introduces no major bump.
check-empty-changeset0✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
check-ci-filter-parity0OK: all 83 declared cross-package glob(s) (72 unique) are covered by core or crosspkg…
check-plugin-teardown-shape0✓ 63 Plugin implementation(s) across 4445 source(s) … baseline fully burned down.
docs-audit/check-affected-docs0per-ledger rows, all reconciled
check:query-options-erasure0✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none new
check:type-check-coverage0check-type-check-coverage: OK — 65/78 workspace packages type-checked …, 1 exempt.
check:type-check-debt --re-measure0check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 245.7s, 1908 raw tsc error(s) total, none above its recorded number.
check:engine-double-contract0check-engine-double-contract: OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.
check:where-matcher0✓ where-matcher conformance holds: 277 matcher(s) discovered … (167 refuse).
check:i18n0check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
check:route-envelope --self-test0✓ check-route-envelope self-test passed
check:dispatcher-error-vocabulary --self-test0check-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK
check:init-service-contract0✓ init-service contract: 34 declared / 1 self-provided / 3 without a workspace provider (68 plugin unit(s) scanned).
check:published-files0✓ 69 publishable package(s) of 78 workspace member(s) declare a files whitelist…

Class #10309 — the derivation did not name check:route-envelope, check:dispatcher-error-vocabulary, check:nul-bytes, check:init-service-contract or check:published-files. All five were run explicitly by judgment anyway; all green. check:init-service-contract in particular was judged warranted because this change edits plugin dependency declarations, which is exactly its population.

check:i18n first returned PREREQUISITE NOT MET — the workspace CLI is not built and Nothing was checked. Recorded as NOT MEASURED, the closure was built, and it was re-run to the real verdict above.

Declared narrowings.

  • @objectstack/service-storage has no typecheck script (a pre-existing DEBT-ledger entry), so the four-package typecheck run type-checked three of the four despite matching all four — the pnpm --filter zero-match trap, verified by counting the echoed typecheck$ tsc --noEmit lines rather than trusting exit 0. Its source is still type-checked by its own tsup DTS build, which passed.
  • The full@objectstack/cli suite (~137 files / ~500s) was not run; 8 targeted files covering every plugin this change touches were. CI runs the farm.
  • @objectstack/plugin-auth TEST_DEBT is untouched at 109; --lower was never run. The ratchet reports a 12-error surplus there — pre-existing, out of scope for this card.

Zero packages/spec edits. Zero content/docs/releases/** edits.


Generated by Claude Code

…nd read audible (#10250)
`SettingsServicePlugin` binds its data engine from a `kernel:ready` hook
registered in its `start()`. `plugin-email`, `service-sms` and
`service-storage` each read a settings namespace from a `kernel:ready` hook
registered in THEIR `start()` — and none of them declared any dependency on
`com.objectstack.service.settings`, so their position relative to the bind was
pure `kernel.use()` order. A reader that started first read the in-memory
fallback, which is empty at boot, and received the manifest DEFAULTS with
`locked: false` and no diagnostic while the operator's saved row sat unread in
`sys_setting`.
- each of the three declares `optionalDependencies:
['com.objectstack.service.settings']` — soft, order-if-present (ADR-0116), so
the kernel orders the bind ahead of the read in every host, and a kernel with
no settings service still boots them unchanged.
- `SettingsService` reports one operator-actionable `warn` per namespace when a
read lands inside a declared-but-pending bind window. Not a refusal: an
in-window read of a setting with no persisted row must answer the manifest
default. Silent after `bindEngine`, on a kernel with no `objectql`
(`settleWithoutEngine`), for a directly constructed `SettingsService`, and
for a read satisfied by an `OS_*` override.
- a new pin derives the slate boundary from what the pin is for: every
always-on entry that is not one of the services others bind into at
`kernel:ready` must be mounted after all of them. The previous assertion
covered `slice(0, 6)` while `sms` — a settings reader — sits at index 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-email, @objectstack/service-settings, @objectstack/service-sms, @objectstack/service-storage, touching 11 documentable anchor(s).

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

  • content/docs/data-modeling/drivers.mdx(via SettingsService (symbol))
  • content/docs/kernel/services.mdx(via EmailServicePlugin (symbol))
  • content/docs/plugins/anatomy.mdx(via optionalDependencies (symbol), com.objectstack.engine.objectql (literal))
  • content/docs/plugins/development.mdx(via com.objectstack.engine.objectql (literal))
  • content/docs/plugins/index.mdx(via com.objectstack.engine.objectql (literal))
  • content/docs/protocol/kernel/config-resolution.mdx(via SettingsService (symbol))
  • content/docs/protocol/kernel/index.mdx(via SettingsService (symbol), com.objectstack.engine.objectql (literal))
  • content/docs/protocol/kernel/lifecycle.mdx(via optionalDependencies (symbol), com.objectstack.engine.objectql (literal))
  • content/docs/protocol/kernel/plugin-spec.mdx(via optionalDependencies (symbol))

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

  • content/docs/releases/v17.mdx(via optionalDependencies (symbol))

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 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 16 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 2866d5f97e9877a438c77ab9b2435a5043ebc0d2packageMentionDocs.

Which tree this was computed on

This run read content/docs from c8f4fb414f53a9a950da514edb883ea9caa7656e — the merge of head a1f8f0740a0faf3cfcd9332a389ca52199285751 into base 2866d5f97e9877a438c77ab9b2435a5043ebc0d2, 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 c8f4fb414f53a9a950da514edb883ea9caa7656e && git checkout c8f4fb414f53a9a950da514edb883ea9caa7656e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 a1f8f0740a0faf3cfcd9332a389ca52199285751 && git checkout -B drift-repro 2866d5f97e9877a438c77ab9b2435a5043ebc0d2 && git merge --no-ff a1f8f0740a0faf3cfcd9332a389ca52199285751
node scripts/docs-audit/affected-docs.mjs --json 2866d5f97e9877a438c77ab9b2435a5043ebc0d2

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

@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.01s
    

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

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

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

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

分诊清单:

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

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

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] A settings READ in the pre-bind window silently resolves to manifest defaults instead of the persisted sys_setting row

2 participants

@os-warren@claude