Found while implementing #10826's getMany (the extraction preserved the behavior faithfully; this predates it — flagging rather than silently widening that PR's scope).
The divergence
SettingsService.loadRows(namespace, userId) answers differently by backend when userId != null:
- engine branch:
if (userId !== null) where.user_id = userId → the find returns ONLY rows with user_id = <userId>. Global-scope and tenant-scope rows carry user_id = NULL, so they are excluded. - memory branch:
userId === null || r.user_id === userId || r.scope === 'tenant' || r.scope === 'global' → the user's rows PLUS tenant and global rows.
Consequence
resolveKey cascades user → tenant → global → defaultwithin the one rows array it was handed. For a user-scope key on an engine-bound deployment — which is every real deployment — a user with no personal row never sees a persisted tenant or global value: the cascade falls straight through to the manifest default. The same read against the in-memory store resolves correctly, which is exactly why the suite is green over a live defect (the fake is looser than the engine — the #4434 class, this time in a WHERE clause rather than a verb).
getMany (#11200) groups user-scope keys under the userId load and tenant/global keys under the null load, so tenant/global keys resolve fine either way; the broken path is specifically the user-scope key's fallback.
Fix shape
Make the engine WHERE mirror the memory predicate — e.g. { namespace, $or: [{ user_id: userId }, { scope: 'tenant' }, { scope: 'global' }] } — and pin it with a test that runs the same fixture through BOTH branches and asserts identical resolution (the differential harness in settings-getmany.test.ts is most of the scaffolding).
Evidence: packages/services/service-settings/src/settings-service.ts, loadRows, verified on origin/main today.
Found while implementing #10826's
getMany(the extraction preserved the behavior faithfully; this predates it — flagging rather than silently widening that PR's scope).The divergence
SettingsService.loadRows(namespace, userId)answers differently by backend whenuserId != null:if (userId !== null) where.user_id = userId→ the find returns ONLY rows withuser_id = <userId>. Global-scope and tenant-scope rows carryuser_id = NULL, so they are excluded.userId === null || r.user_id === userId || r.scope === 'tenant' || r.scope === 'global'→ the user's rows PLUS tenant and global rows.Consequence
resolveKeycascadesuser → tenant → global → defaultwithin the one rows array it was handed. For a user-scope key on an engine-bound deployment — which is every real deployment — a user with no personal row never sees a persisted tenant or global value: the cascade falls straight through to the manifest default. The same read against the in-memory store resolves correctly, which is exactly why the suite is green over a live defect (the fake is looser than the engine — the #4434 class, this time in a WHERE clause rather than a verb).getMany(#11200) groups user-scope keys under the userId load and tenant/global keys under the null load, so tenant/global keys resolve fine either way; the broken path is specifically the user-scope key's fallback.Fix shape
Make the engine WHERE mirror the memory predicate — e.g.
{ namespace, $or: [{ user_id: userId }, { scope: 'tenant' }, { scope: 'global' }] }— and pin it with a test that runs the same fixture through BOTH branches and asserts identical resolution (the differential harness insettings-getmany.test.tsis most of the scaffolding).Evidence:
packages/services/service-settings/src/settings-service.ts,loadRows, verified on origin/main today.