Found while implementing #10250 (PR #11044). Filed separately rather than widening that card: the ruling there names three specific plugins and their declarations, and building a gate is a different, larger piece of work. Filed unassigned.
Observation
#10250's fix makes plugin-email, service-sms and service-storage each declare
optionalDependencies=['com.objectstack.service.settings'];
so the kernel orders SettingsServicePlugin's engine bind ahead of their kernel:ready settings reads. That closes the three measured readers.
Nothing checks that a fourth one declares it. A plugin added tomorrow that acquires a settings handle in a kernel:ready hook and forgets the declaration is back in the pre-bind window, and the only thing that will say so is the runtime warn PR #11044 adds — which requires someone to be reading boot logs of a deployment whose composition order happens to be wrong.
Why the existing gate does not cover it
scripts/check-init-service-contract.mjs (check:init-service-contract) is exactly the right shape — ADR-0116 / #4471, "declaring is NOT voluntary" — but its population is init()-reachable getService calls. The settings bind window is not an init() fact:
SettingsServicePlugin registers its bind hook from start(), not init();- the readers acquire their handle inside a
kernel:ready hook registered from start(); - so both sides of the ordering constraint are invisible to a walk rooted at
init().
Measured on origin/main at 2866d5f97: check:init-service-contract reports 34 declared / 1 self-provided / 3 without a workspace provider (68 plugin unit(s) scanned) and is green both before and after the three declarations land — i.e. it is indifferent to the property this card is about.
The population, measured
Shipped settings-handle acquisitions (getService[^(]*\(\s*['"]settings['"], tests and dist/ excluded; the regex was proved on three known readers before its silence was read anywhere) — 11 real acquisitions across 13 hits, the other 2 being string literals inside service-settings comments:
| site | in an always-on provider? | declares the edge after PR #11044? |
|---|
packages/plugins/plugin-email/src/email-plugin.ts:388 | yes (email) | yes |
packages/services/service-sms/src/sms-plugin.ts:189 | yes (sms) | yes |
packages/services/service-storage/src/storage-service-plugin.ts:406 | yes (storage) | yes |
packages/plugins/plugin-auth/src/auth-plugin.ts:703 | no | no — but defends itself via resolved.source !== 'default' |
packages/plugins/plugin-auth/src/auth-plugin.ts:1182 | no | no — not analysed |
packages/plugins/plugin-audit/src/audit-plugin.ts:200 | no | no — not analysed |
packages/mcp/src/plugin.ts:301 | no | no — not analysed |
packages/rest/src/rest-api-plugin.ts:299 | no | no — not analysed |
packages/objectql/src/plugin.ts:436, :509 | no | no — not analysed |
packages/runtime/src/security/resolve-execution-context.ts:204 | no | request-time, not boot-time |
⚠️ The five not analysed rows are the honest part of this card: whether each of them reaches its getService('settings') from a hook registered before SettingsServicePlugin.start() has not been established. #10250's census established it only for the three always-on providers. That analysis is the first step here, and it may well come back "none of them" — which would make this a hygiene card rather than a defect card.
What a gate would have to do
Walk each plugin's start()-registered kernel:ready hook body (transitively, like the init walk does) for a getService('settings') and require com.objectstack.service.settings in that plugin's dependencies / optionalDependencies.
Two known hazards for whoever picks this up, both measured on the #10250 census:
- a name-based call-graph walker's transitive arm is the part that fails quietly. That census's walker hit 4 of 6 known readers, every hit at depth 0, and an earlier revision mis-parsed every
(ctx as any).hook(...) site and returned 1-line bodies for 8 of 16 hooks — "a zero that looked completely clean". A gate resting on that walk needs a --self-test that pins the transitive arm, not just depth 0. - multi-line function signatures drop out of a naive definition index (
bootAutoEnqueue( was the measured case).
Adjacent, not duplicates
#4471 / #4131 established the init()-phase contract and produced the existing gate; this is the same argument one lifecycle phase later. #10250 is the defect this generalises from and is being fixed narrowly, by declaration, on the three measured readers.
Found while implementing #10250 (PR #11044). Filed separately rather than widening that card: the ruling there names three specific plugins and their declarations, and building a gate is a different, larger piece of work. Filed unassigned.
Observation
#10250's fix makes
plugin-email,service-smsandservice-storageeach declareso the kernel orders
SettingsServicePlugin's engine bind ahead of theirkernel:readysettings reads. That closes the three measured readers.Nothing checks that a fourth one declares it. A plugin added tomorrow that acquires a settings handle in a
kernel:readyhook and forgets the declaration is back in the pre-bind window, and the only thing that will say so is the runtimewarnPR #11044 adds — which requires someone to be reading boot logs of a deployment whose composition order happens to be wrong.Why the existing gate does not cover it
scripts/check-init-service-contract.mjs(check:init-service-contract) is exactly the right shape — ADR-0116 / #4471, "declaring is NOT voluntary" — but its population isinit()-reachablegetServicecalls. The settings bind window is not aninit()fact:SettingsServicePluginregisters its bind hook fromstart(), notinit();kernel:readyhook registered fromstart();init().Measured on
origin/mainat2866d5f97:check:init-service-contractreports34 declared / 1 self-provided / 3 without a workspace provider (68 plugin unit(s) scanned)and is green both before and after the three declarations land — i.e. it is indifferent to the property this card is about.The population, measured
Shipped settings-handle acquisitions (
getService[^(]*\(\s*['"]settings['"], tests anddist/excluded; the regex was proved on three known readers before its silence was read anywhere) — 11 real acquisitions across 13 hits, the other 2 being string literals insideservice-settingscomments:packages/plugins/plugin-email/src/email-plugin.ts:388email)packages/services/service-sms/src/sms-plugin.ts:189sms)packages/services/service-storage/src/storage-service-plugin.ts:406storage)packages/plugins/plugin-auth/src/auth-plugin.ts:703resolved.source !== 'default'packages/plugins/plugin-auth/src/auth-plugin.ts:1182packages/plugins/plugin-audit/src/audit-plugin.ts:200packages/mcp/src/plugin.ts:301packages/rest/src/rest-api-plugin.ts:299packages/objectql/src/plugin.ts:436,:509packages/runtime/src/security/resolve-execution-context.ts:204getService('settings')from a hook registered beforeSettingsServicePlugin.start()has not been established. #10250's census established it only for the three always-on providers. That analysis is the first step here, and it may well come back "none of them" — which would make this a hygiene card rather than a defect card.What a gate would have to do
Walk each plugin's
start()-registeredkernel:readyhook body (transitively, like the init walk does) for agetService('settings')and requirecom.objectstack.service.settingsin that plugin'sdependencies/optionalDependencies.Two known hazards for whoever picks this up, both measured on the #10250 census:
(ctx as any).hook(...)site and returned 1-line bodies for 8 of 16 hooks — "a zero that looked completely clean". A gate resting on that walk needs a--self-testthat pins the transitive arm, not just depth 0.bootAutoEnqueue(was the measured case).Adjacent, not duplicates
#4471 / #4131 established the
init()-phase contract and produced the existing gate; this is the same argument one lifecycle phase later. #10250 is the defect this generalises from and is being fixed narrowly, by declaration, on the three measured readers.