Uh oh!
There was an error while loading. Please reload this page.
fix(settings): declare the settings ordering edge and make the pre-bind read audible - #11044
Conversation
…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
📓 Docs Drift CheckThis PR changes 4 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32576087437 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
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
SettingsServicePluginbinds its data engine from akernel:readyhook registered in itsstart(). Three shipped plugins read a settings namespace from akernel:readyhook registered in theirstart():packages/plugins/plugin-email/src/email-plugin.ts:388getNamespace('mail')— SMTP / provider / from-addresspackages/services/service-sms/src/sms-plugin.ts:189getNamespace('sms')— provider credentials, daily cost ceilingpackages/services/service-storage/src/storage-service-plugin.ts:406getNamespace('storage')— backend + credentialsHooks 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 withsource: 'default'andlocked: false, with no diagnostic anywhere, while the operator's saved row sat unread insys_setting.Nothing constrained that order. None of the three declared any dependency on
com.objectstack.service.settings, so their position was purekernel.use()order. It was correct underos serveonly because the always-on slate listssettingsahead of them — andserveprepends an app's declaredrequireswhile only appending the slate, so an ordinaryrequires: ['email']produced email-before-settings and bypassed it. Cloud'sobjectos-runtimemounts the slate from its own wiring, which is why a CLI-only repair (option D) was rejected.The card's own population guess (
kernel:readyhooks registered frominit()) 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:
ObjectKernel.bootstrapandLiteKernel.bootstrapboth iterate the sameresolvePluginOrderoutput 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.loadRowsnow reports one operator-actionablewarnper namespace when a read lands in a declared-but-pending bind window: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
loadRowsrather thanget()because that is the precise point at which persisted state would have been consulted — an in-window read satisfied by anOS_*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 noobjectqlat all (settleWithoutEngine); a directly constructedSettingsService; 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):smsis 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 leavesharing/messaging/analyticsand 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 ownkernel:readyphase."An eleventh entry added tomorrow is covered the moment it lands, wherever it goes; one inserted before
settingsturns 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.slice(0, 6)assertion and its "grow the slate AFTER these six" comment live inpackages/spec/src/kernel/platform-capabilities.test.ts, and this lane has zeropackages/specownership. 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'srequires. 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.tsimports its subject relatively (./settings-service.js) → resolves to siblingsrc, no build between edit and verdict.packages/cli/src/commands/serve-settings-ordering.pin.test.tsimports bare workspace specifiers, all of which sit inKNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']and none of whichpackages/cli/vitest.config.tsaliases → resolves todist/. Every leg of the manifest ablation therefore rebuilds and proves the marker's presence/absence indistbefore 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) — sodistis the deliberate answer here, not an oversight.Ablations — signatures predicted in writing before mutating.
reportPreBindReadloadRowsnever reaches the reporter. Right direction, wrong magnitude, for a reason the code makes inevitablemockClear()runs after the per-namespace dedupe was already consumed at boot)service-smsmanifest, rebuildsms, order revertssms must declare the settings ordering edge: expected [] to include …andsms: settings must resolve ahead of the reader: expected 2 to be less than 1Restores proved byte-identical with
git hash-object:A3 dist state on each leg (
scripts/ablation-dist-preflight.mjs), plus the pnpm workspace symlink asserted on both — nopackage.jsondependency edits are in this change, so the link is not a variable, but it was checked anyway: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 insideservice-settingscomments. Of the ten always-on providers, exactly three acquire a settings handle:plugin-email,service-storage,service-sms;queue,job,cache,sharing,messaging,analyticsreturn zero on an instrument already shown to see readers.Suites (all at
a1f8f0740, clean tree):Gate union derived on the final commit
a1f8f0740with a clean tree,node scripts/pm/dispatch-gates.mjsand 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.check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/…check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:nul-bytescheck-nul-bytes: OK (scanned 6392 text file(s) … no raw ASCII control bytes).check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new…check:type-source-resolutioncheck-type-source-resolution OK — 77 packages with a tsconfig.json scanned…check:changeset-gate-self-tests✓ check-changeset-no-major --self-test: 116 assertions…check:objectui-changeset✓ #7004 C7 control — … C1-C6 are about the entry regex…check-adr-0087-registration✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check-changeset-no-major✓ This diff introduces no major bump.check-empty-changeset✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check-ci-filter-parityOK: all 83 declared cross-package glob(s) (72 unique) are covered by core or crosspkg…check-plugin-teardown-shape✓ 63 Plugin implementation(s) across 4445 source(s) … baseline fully burned down.docs-audit/check-affected-docscheck:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none newcheck:type-check-coveragecheck-type-check-coverage: OK — 65/78 workspace packages type-checked …, 1 exempt.check:type-check-debt --re-measurecheck-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-contractcheck-engine-double-contract: OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.check:where-matcher✓ where-matcher conformance holds: 277 matcher(s) discovered … (167 refuse).check:i18ncheck-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).check:route-envelope --self-test✓ check-route-envelope self-test passedcheck:dispatcher-error-vocabulary --self-testcheck-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OKcheck:init-service-contract✓ init-service contract: 34 declared / 1 self-provided / 3 without a workspace provider (68 plugin unit(s) scanned).check:published-files✓ 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-contractorcheck:published-files. All five were run explicitly by judgment anyway; all green.check:init-service-contractin particular was judged warranted because this change edits plugin dependency declarations, which is exactly its population.check:i18nfirst returnedPREREQUISITE NOT MET — the workspace CLI is not builtandNothing was checked. Recorded as NOT MEASURED, the closure was built, and it was re-run to the real verdict above.Declared narrowings.
@objectstack/service-storagehas notypecheckscript (a pre-existing DEBT-ledger entry), so the four-package typecheck run type-checked three of the four despite matching all four — thepnpm --filterzero-match trap, verified by counting the echoedtypecheck$ tsc --noEmitlines rather than trusting exit 0. Its source is still type-checked by its owntsupDTS build, which passed.@objectstack/clisuite (~137 files / ~500s) was not run; 8 targeted files covering every plugin this change touches were. CI runs the farm.@objectstack/plugin-authTEST_DEBT is untouched at 109;--lowerwas never run. The ratchet reports a 12-error surplus there — pre-existing, out of scope for this card.Zero
packages/specedits. Zerocontent/docs/releases/**edits.Generated by Claude Code