Uh oh!
There was an error while loading. Please reload this page.
fix(core,rest): declare the getMany the localization resolver actually calls, and pin its leg count - #11677
Conversation
…lly calls, and pin its leg count The #10826 switch from three `settings.get()` calls to one `settings.getMany()` left two declarations describing the old consumer, and left the metric that switch was calibrated on unmeasured. - `ResolveLocalizationInput.settings`'s param JSDoc and `rest`'s `SettingsReadSurface` both said `get` was the one method consumed. Neither is type-checked at the call site (`getService` is a cast, `rest-server.ts` widens to a bare promise, the resolver receives `settings?: any`), which is why CI was green without them — the cost is a host author being told `getMany` is not consumed. `getMany` is declared OPTIONAL: the branch is feature-detected and an occupant without it still answers, at three reads instead of one. - Legs, not calls: the existing pins count calls, so turning the per-key fallback's `Promise.all` into a sequential loop would take legs 1 -> 3 with every assertion still green. `makeSettingsRig` measures row loads that start while nothing else is in flight — the sequential wave count cloud#1539 used. - The `getMany` double now takes the third parameter, so the resolution context it is called with is observable for the first time. Item 4 (partial-manifest all-or-nothing) is recorded as this caller's degradation; the rule itself is the settings service's contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
📓 Docs Drift Check4 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 29 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 6605f80194c95b44a72b3f4890a11ea0e4f62aac && git checkout 6605f80194c95b44a72b3f4890a11ea0e4f62aac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88b9d749a1eb76b53966b0081006d2c5bccb341d 266eb589ffefedc071cd00e451389a61f7025610 && git checkout -B drift-repro 88b9d749a1eb76b53966b0081006d2c5bccb341d && git merge --no-ff 266eb589ffefedc071cd00e451389a61f7025610
node scripts/docs-audit/affected-docs.mjs --json 88b9d749a1eb76b53966b0081006d2c5bccb341d |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11222
The #10826 switch from three
settings.get()calls to onesettings.getMany()left two declarations describing the old consumer, left the metric that switch
was calibrated on unmeasured, and left the resolution context unobservable by
any pin. Three items, one PR, spanning
core+restas triage designated.1. Two declarations now say what the consumer actually calls
resolveLocalizationContexthas calledgetManysince #10826. Two declarationsstill said
getwas the one method consumed:packages/core/src/security/resolve-authz-context.ts— the param JSDoc onResolveLocalizationInput.settings.packages/rest/src/rest-api-plugin.ts—interface SettingsReadSurface, whoseprose read "The one method the platform consumes is
get".getManyis declared OPTIONAL, not required, and that is the load-bearingchoice: the branch is feature-detected, so an occupant without
getManystillanswers correctly at three reads instead of one. Declaring it required would
over-state the contract as badly as omitting it under-stated it.
Nothing was broken and the reason is itself worth writing down, so both sites now
say it: the type is erased three times before the call site —
ctx.getServiceisa cast,
rest-server.tswidens the provider's return to a bare promise, and theresolver receives
settings?: any— sosettings.getManyis type-checkednowhere. That is why CI was green without the declaration, and why these
interfaces are documentation for host authors rather than enforcement.
No fourth site. Verified:
runtime's own lookup(
resolve-execution-context.ts) callsopts.getService('settings')untyped, andplugin-auth/plugin-emailname their ownSettingsReadSurfacebut call nogetMany(grep: zero hits in both) — their narrower declarations are accurateand deliberately untouched.
2. Legs, not calls — the card's own metric, now pinned
#10826's calibration is "a query-count fix, not a latency fix", because
cloud#1539 measured the three reads running in parallel: 3 queries but 1 leg,
and legs are the latency multiplier. Every pin in this file counts calls
(
getMany.calls,gets === 3), so the leg count was pinned by nothing.makeSettingsRigmeasures a leg: a row load that starts while nothing elseis in flight — the number of sequential round-trip waves. Three loads issued
inside one
Promise.allall increment before any resumes past its firstawait,so they count as one; three sequential awaits count as three.
Reverse-verified in both directions — a leg pin that has never been seen red
is not a pin:
Feature-detect disabled (
if (false && typeof settings.getMany === 'function'))— the batched occupant falls to the per-key path:
That is the salvaged rig's original failure line, reproduced against the landed
source: queries 3 -> 1, legs 1 -> 1.
The per-key
Promise.allreplaced with a sequentialforloop — the exactregression class item 2 exists to catch:
69 of 70 stayed green. Every pre-existing call-count assertion — including
gets === 3andgetMany.calls === 1— passed while legs went 1 -> 3. The newleg pin is the only assertion in the file that can see it, which is precisely
the gap being closed.
Both ablations restored via an
EXIT INT TERMtrap and confirmed on disk byanchor grep counts before and after each mutation (
Promise.all2 -> 1,marker 0 -> 1, and back). No rebuild leg applies: the test imports the resolver
by relative specifier (
./resolve-authz-context.js), so vitest resolves source,never
dist/.3. The resolution context, observable for the first time
The existing
getManydouble is declared(ns, keys)— two parameters — soit structurally cannot see the third argument. Drop
sctxfrom the productioncall today and all three pins stay green. The rig's double takes
ctx, and thenew pin asserts the whole call shape: namespace, key list, and
{ tenantId: 'o1', userId: 'u1' }. It went red under ablation 1 alongside theleg pin.
4. Partial-manifest all-or-nothing — split producer/consumer, not widened
Judgement called as the card invited. The all-or-nothing rule is
SettingsService.getMany's own contract (it validates every key up front andthrows
UnknownKeyErrorfor the whole call), so documenting the rule belongs onthe producer, in
packages/services/service-settings— adomain:servicescard,filed separately rather than silently widened into this PR's package set.
What is this caller's own and is recorded here: when
getManythrows, thisresolver drops to the direct tenant-scoped
$inread, which has noglobalscope layer and no
OS_LOCALIZATION_*env override. The existing comment saidthe fallback "reads the exact same three keys" without saying it reads them
through a shorter cascade. Degradation, never a wrong answer, and unreachable
against the in-repo
localizationSettingsManifest, which declares all three keys.Verification
pnpm --filter @objectstack/core exec vitest run src/security/resolve-authz-context.test.ts— Test Files 1 passed (1) · Tests 70 passed (70).
pnpm --filter @objectstack/rest typecheck— exit 0, script name echoed(
> @objectstack/rest@17.2.0 typecheck), so it is not the zero-match no-op.Gates run at final HEAD
266eb589, each read from its own printed verdict line:check:authz-resolver·check:cross-package-test-inputs·check:dispatcher-error-vocabulary·check:kernel-hook-pairs·check:published-files·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:nul-bytes·check:type-check-coverage·check-plugin-teardown-shape·docs-audit/check-affected-docs— all exit 0.@objectstack/core's tsconfig isinclude: ["src/**/*"], exclude: [], so its*.test.tsfiles are inside the tsc program the 98-error DEBT ratchetfreezes. Measured directly rather than assumed:
tsc --noEmit -pon this branchreports 98 errors, unchanged, and zero of them in
resolve-authz-context.test.ts.Declared narrowing (repo-wide
pnpm lintleft to CI): eslint run with--no-inline-configover the three changed files,--format jsonreporting3 files linted, 0 errors, 0 warnings. The narrowing excludes nothing,
because this repo's
eslint.config.mjsnever enables type-aware linting — noparserOptions.project, no typed@typescript-eslintrules, for any file(stated and measured in the config's own comment) — so this diff cannot move the
verdict on a file it does not touch.
No changeset
Nothing here releases: no runtime path moves, no exported type changes,
SettingsReadSurfaceis not exported, and the rest is JSDoc, a code comment andtests.
skip-changesetapplied per the workflow's own prescription for a PR thatdeclares no release of its own. Say the word and a patch changeset goes in
instead.
Scope
Three files exactly, as claimed.
packages/plugins/plugin-authandplugin-emaildeclare their ownSettingsReadSurfaceand were deliberately leftalone.
packages/core/src/security/auth-gate.ts(hold #7898's indexed path) wasnot touched.
Generated by Claude Code