Found while measuring #9451's A′ preconditions (that card needed to know whether a kernel:ready migration can write a durable receipt through SettingsService). Filing separately rather than widening that card. Not a live defect today — no shipped caller writes settings at that moment — so this is a latent trap, recorded before someone walks into it.
Observation
SettingsService keeps an in-process memory array as its fallback store, and upsertRow picks between the engine and that array on if (this.engine). The engine is bound in exactly one place — SettingsServicePlugin.start() registers a kernel:ready hook, and bindEngine is called inside it (packages/services/service-settings/src/settings-service-plugin.ts:202).
kernel:ready handlers run in registration order, and every plugin's init() runs before any plugin's start(). So any hook registered during init() fires before the settings plugin has bound its engine — and a set() from such a hook returns a fully resolved value while nothing reaches sys_setting.
That is not a hypothetical position: assembleMetadataProtocol registers its three platform migrations' kernel:ready hook from ObjectQLPlugin.init() (packages/objectql/src/plugin.ts:350 → packages/metadata-protocol/src/plugin.ts:306), which is the exact window.
Measured
Real LiteKernel, real ObjectQLPlugin over a real SqlDriver (better-sqlite3), real SettingsServicePlugin, and a probe plugin registering its kernel:ready hook from init():
serviceResolvableAtReady : true
engineBoundAtReady : false
setAtReady : resolved:"written-at-kernel-ready" <- reports success
rowsInDbAfterBoot : [] <- nothing persisted
engineBoundAfterBoot : true <- control, same boot
rowsInDbAfterControlWrite: [ { namespace: 'receipt_probe', key: 'last_run',
scope: 'global', organization_id: null, … } ]
auditRowsAfterControlWrite: [ { action: 'set', new_hash: 'fnv32:…' } ]
The control write moments later lands a real sys_setting row and a real sys_setting_audit row on the same connection, so the table existed throughout — the timing is the whole cause.
Why it is worth recording
This is the shape AGENTS.md's degradation-log-level rule exists for, one level up: a write that claims to persist does not, nothing looks broken from the outside, and there is no log line at any level — the write did not fail, it succeeded against the wrong store. Both audit ledgers are silent too (no engine ⇒ no sys_setting_audit row, no config_change row), so the usual evidence that a settings write happened is also absent.
Options
- A. Make an engine-less write refuse or report, at least for a non-test kernel: the memory fallback is documented for "unit tests, bootstrap, control-plane mock", and it is the bootstrap reading that is doing the damage here.
- B. Bind the engine earlier (the service already resolves
objectql lazily elsewhere) so the window closes rather than being reported. - C. Leave the behaviour and document the window on
SettingsService + the kernel:ready contract, so a caller knows the earliest safe moment is kernel:bootstrapped. - D. Accept as-is: no shipped caller writes there today.
No recommendation strong enough to pre-empt triage — the memory fallback is deliberate and has other users, so this is a decision about which of its readings it should keep.
Filed unassigned.
Found while measuring #9451's A′ preconditions (that card needed to know whether a
kernel:readymigration can write a durable receipt throughSettingsService). Filing separately rather than widening that card. Not a live defect today — no shipped caller writes settings at that moment — so this is a latent trap, recorded before someone walks into it.Observation
SettingsServicekeeps an in-processmemoryarray as its fallback store, andupsertRowpicks between the engine and that array onif (this.engine). The engine is bound in exactly one place —SettingsServicePlugin.start()registers akernel:readyhook, andbindEngineis called inside it (packages/services/service-settings/src/settings-service-plugin.ts:202).kernel:readyhandlers run in registration order, and every plugin'sinit()runs before any plugin'sstart(). So any hook registered duringinit()fires before the settings plugin has bound its engine — and aset()from such a hook returns a fully resolved value while nothing reachessys_setting.That is not a hypothetical position:
assembleMetadataProtocolregisters its three platform migrations'kernel:readyhook fromObjectQLPlugin.init()(packages/objectql/src/plugin.ts:350→packages/metadata-protocol/src/plugin.ts:306), which is the exact window.Measured
Real
LiteKernel, realObjectQLPluginover a realSqlDriver(better-sqlite3), realSettingsServicePlugin, and a probe plugin registering itskernel:readyhook frominit():The control write moments later lands a real
sys_settingrow and a realsys_setting_auditrow on the same connection, so the table existed throughout — the timing is the whole cause.Why it is worth recording
This is the shape AGENTS.md's degradation-log-level rule exists for, one level up: a write that claims to persist does not, nothing looks broken from the outside, and there is no log line at any level — the write did not fail, it succeeded against the wrong store. Both audit ledgers are silent too (no engine ⇒ no
sys_setting_auditrow, noconfig_changerow), so the usual evidence that a settings write happened is also absent.Options
objectqllazily elsewhere) so the window closes rather than being reported.SettingsService+ thekernel:readycontract, so a caller knows the earliest safe moment iskernel:bootstrapped.No recommendation strong enough to pre-empt triage — the memory fallback is deliberate and has other users, so this is a decision about which of its readings it should keep.
Filed unassigned.