Uh oh!
There was an error while loading. Please reload this page.
fix(core): only a backend fault populates the localization failure memo (#11877) - #12044
Conversation
`resolveLocalizationContext` memoized an outcome for 30s whenever the read "failed", but six legs set that flag and only one of them is the backend fault the cache's own docblock describes (the direct `ql.find` throw). The other five are the settings service refusing — a thrown `getMany`, the three older per-key `get`s, and the whole-block "service unavailable" handler. Those five are reachable inside the settings engine's bind window, so a caller that deliberately re-reads after the bind was answered from the in-window memo for up to 30s, and a settings refusal standing alongside a SUCCESSFUL direct read froze that successful value — the staleness the docblock forbids outright. The memo is now written only for the direct-read fault. #10221's protection is unchanged for the legs it was built for: its environment still memoizes, because the direct read throws there whether or not a settings refusal stands in front of it. Both directions are pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 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 280a026fb9bd57df49b9d0fb697703fe4cdd135c && git checkout 280a026fb9bd57df49b9d0fb697703fe4cdd135c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4f24e9d2e1294de6628d196c4001c71053c11179 5ce9ca42f423366e3c5bedd8fe2343575d8148eb && git checkout -B drift-repro 4f24e9d2e1294de6628d196c4001c71053c11179 && git merge --no-ff 5ce9ca42f423366e3c5bedd8fe2343575d8148eb
node scripts/docs-audit/affected-docs.mjs --json 4f24e9d2e1294de6628d196c4001c71053c11179 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11877
resolveLocalizationContextmemoizes an outcome for 30s whenever the underlyingread "failed" (#10221 — so a repeatedly-failing
sys_settingquery does notre-run, and the driver does not re-log it, on every request). The write
condition was wider than the cache's own docblock, and the memo could answer a
deliberate post-bind re-read with the pre-bind value.
The leg split, re-enumerated on this branch
failedis set in six places and that "only two of them arethe backend fault". A raw
grep -c 'failed = true'onorigin/mainreturnsseven — but one of those is a docblock mention, not an assignment
(
resolve-authz-context.ts:830, thegetManycomment quoting the flag name).So there are six real legs, unchanged from the card. No seventh leg landed;
the extra hit is prose.
The card's classification is what had moved — and it understates the defect.
Re-enumerated by hand on
origin/main(c804f0ca5f)::853settings.getMany('localization', …)threw:858settings.get(…, 'timezone', …)rejected:862settings.get(…, 'locale', …)rejected:866settings.get(…, 'currency', …)rejected:880settings service unavailable → direct read:901ql.find('sys_setting', …)threwFive of six are the settings service, and exactly ONE is the backend fault the
docblock describes — not "four of six / two backend" as filed.
:880isclassed by its own comment: it can only fire for a throw out of the settings
block. This changes nothing about the chosen route, but it is the number the
fix is built on, so it is stated rather than carried forward.
The reproduction — this card was filed without one, and it reproduces
Built first, before any repair, and measured as the ablation leg below
(
packages/core/src/security/resolve-authz-context.test.ts). The clock isfake and advanced explicitly (
vi.useFakeTimers()+advanceTimersByTimeAsync) — nothing here sleeps on the wall clock.Pre-bind read inside the window (settings refuses all-or-nothing for a
localizationnamespace whose manifest is not yet registered;sys_settinganswers an ordinary empty result) → the bind lands → deliberate re-read 1s
later, deep inside the 30s TTL. On unmodified
main:And the second, worse shape — a settings refusal standing alongside a perfectly
successful direct read froze that successful value, which the cache's own
docblock forbids outright ("a successful read … is NEVER cached"):
premise_still_valid: true.The repair
The memo is written only for the direct-read fault.
resolveLocalizationContextUncached'sinternal
failedflag becomesbackendFailed, set at:901and nowhere else;the five settings legs keep their existing fall-through to the direct
$inread and no longer populate the memo.
#10221's protection is intact for the legs it was built for. Two independent
reasons, both measured:
read throws there whether or not a settings refusal stands in front of it.
Pinned by a new case that puts a refusing settings service in front of a
throwing
ql.findand asserts the failing query still runs exactly onceinside the TTL, and twice across it.
SettingsService.getManyrefuses out of an in-memory registry check (
UnknownNamespaceError/UnknownKeyError,settings-service.ts:1227-1230) — thrown before anyquery and before any log line — so memoizing those legs suppressed neither a
query nor a log line. The service's own pre-bind diagnostic is separately
deduped by the service, per namespace (
reportPreBindRead).No signature, export, accepted-input or return-shape change reaches outside the
module:
resolveLocalizationContextUncachedis module-local, andresolveLocalizationContext's own signature is untouched. Clause-② stays no— no contract accept/reject door moves and no public surface widens.
Non-vacuity, both directions
maingetlegs do not populate the memoThe last two rows pass on both trees. That is the point of them — they guard
the half the fix must not move, so they are not evidence for the half it does.
Ablation
Direction predicted in writing before the run: exactly 4 of the 5 new cases
fail on the un-narrowed source; the 5th and all pre-existing cases pass on both
trees.
the text actually being changed (not
git diff --stat, not an editor exitcode): narrowed tree
'backendFailed = true' = 1,'failed = true' = 0;ablated tree
'backendFailed = true' = 0,'failed = true' = 7(6 legs + the 1 docblock mention).
trap restore EXIT INT TERMwith absolute paths (norepo-relative path after a
cd), then verified rather than trusted:cmp -sreportedRESTORE_VERIFIED=byte-identical, the post-restore anchoredcount returned to 1, and
git statusshows no stray file.of the suite's name. The unit pin imports its subject as
./resolve-authz-context.js— a relative, intra-package specifier vitestresolves to
src, never through the package'sexports→dist. Where thehazard does apply —
packages/qa/dogfood, which consumesdistdeliberately — the artifact was rebuilt and proved before its result was read
(below).
Measured:
Tests 4 failed | 71 passed (75), failing exactly the four predictedcases. On this branch:
Test Files 1 passed (1) · Tests 75 passed (75).The golden-regression pin was NOT touched
packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts(#1982/#2018 — thereason this cache was narrowed to failures in the first place) is unmodified
and green. Because that suite resolves
@objectstack/corethroughdist, thepackage was rebuilt and the change proved present in the built artifact before
the result was read:
This change only ever removes cache writes; it adds none. There is no
direction in which it can make a read staler than
mainalready allows.Verification
Every run below is at
5ce9ca42f4— the final commit on this branch — andevery exit code was captured before any pipe (
cmd > log 2>&1; EXIT=$?),never as
$?after a| tail. Verdicts are quoted from what each gate printedfor itself.
Gate union — derived, not recalled.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the actual diff (it reported
gate list derived from the tree of 'objectstack-ai/objectstack' at commit 5ce9ca42f4,3 path(s) vs merge base c804f0ca5,--repo … checked against this checkout's 'origin' remote — it holds). All 18 path-matched families plus the 6 convention-triggered ones ran;all exit 0:
Suites — the owning package plus every in-repo caller of
resolveLocalizationContext, all exit 0:Note:
packages/coredeclares notypecheckscript, so none was run for it— a
pnpm --filter @objectstack/core typecheckwould have matched zero scriptsand exited 0 while checking nothing. Its type coverage is asserted by the two
ledger gates above, both green, over a freshly built workspace closure
(
turbo run build --filter=./packages/* --filter=./packages/*/*→70 successful, 70 total).pnpm lint(repo-wideeslint . --no-inline-config) was narrowed, and thenarrowing is declared and measured — three pieces, not an omission:
directly (
ESLint#isPathIgnored/calculateConfigForFile) about all threechanged paths: the two
.tsfiles are governed (ignored=false,rules present); the changeset
.mdisignored=trueby eslint's own config.So the eslint-governed population of this diff is exactly 2 files, and the
narrowed run covered 100% of it.
--format json, not from my summary:2results,errors=0 warnings=0on both, exit 0.eslint.config.mjsthatnever enables type-aware linting for any file — no
parserOptions.project,no typed
@typescript-eslintrules — stated and positively-controlled in theconfig itself (
eslint.config.mjs:326-335). A verdict for an untouched filetherefore cannot be a function of this diff.
CI runs the full farm regardless; the union above is the cheap half, owed and paid.
Generated by Claude Code
Generated by Claude Code