Skip to content

fix(core): only a backend fault populates the localization failure memo (#11877) - #12044

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-11877-localization-failure-cache-legs
Aug 25, 2026
Merged

fix(core): only a backend fault populates the localization failure memo (#11877)#12044
os-warren merged 1 commit into
mainfrom
claude/issue-11877-localization-failure-cache-legs

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#11877

resolveLocalizationContext memoizes an outcome for 30s whenever the underlying
read "failed" (#10221 — so a repeatedly-failing sys_setting query does not
re-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

⚠️ The card says failed is set in six places and that "only two of them are
the backend fault". A raw grep -c 'failed = true' on origin/main returns
seven — but one of those is a docblock mention, not an assignment
(resolve-authz-context.ts:830, the getMany comment 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):

linelegkind
:853settings.getMany('localization', …) threwsettings service
:858settings.get(…, 'timezone', …) rejectedsettings service
:862settings.get(…, 'locale', …) rejectedsettings service
:866settings.get(…, 'currency', …) rejectedsettings service
:880the whole settings block threw — the handler's own comment reads settings service unavailable → direct readsettings service
:901ql.find('sys_setting', …) threwbackend fault

Five of six are the settings service, and exactly ONE is the backend fault the
docblock describes
— not "four of six / two backend" as filed. :880 is
classed 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 is
fake 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
localization namespace whose manifest is not yet registered; sys_setting
answers an ordinary empty result) → the bind lands → deliberate re-read 1s
later, deep inside the 30s TTL. On unmodified main:

AssertionError: expected 1 to be 2 // settings.getMany call count —
// the post-bind re-read never
// reached the now-bound service

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"):

AssertionError: expected 'UTC' to be 'America/Los_Angeles'

premise_still_valid: true.

The repair

The memo is written only for the direct-read fault. resolveLocalizationContextUncached's
internal failed flag becomes backendFailed, set at :901 and nowhere else;
the five settings legs keep their existing fall-through to the direct $in
read and no longer populate the memo.

#10221's protection is intact for the legs it was built for. Two independent
reasons, both measured:

  1. 新环境日志被 sys_setting 'no such table' ERROR 刷屏:本地化读取先于建表,真错误被噪音淹没 #10221's environment (table not migrated yet) still memoizes — the direct
    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.find and asserts the failing query still runs exactly once
    inside the TTL, and twice across it.
  2. Nothing was lost on the narrowed legs anyway: SettingsService.getMany
    refuses out of an in-memory registry check (UnknownNamespaceError /
    UnknownKeyError, settings-service.ts:1227-1230) — thrown before any
    query 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: resolveLocalizationContextUncached is module-local, and
resolveLocalizationContext's own signature is untouched. Clause-② stays no
— no contract accept/reject door moves and no public surface widens.

Non-vacuity, both directions

pindirectionon mainon this branch
post-bind re-read inside the TTL reaches the bound servicethe half that CHANGES✗ fail
a settings refusal never freezes a SUCCESSFUL direct readthe half that CHANGES✗ fail
the three per-key get legs do not populate the memothe half that CHANGES✗ fail
the outer "service unavailable" leg does not populate the memothe half that CHANGES✗ fail
a settings refusal in FRONT of a genuine backend fault still memoizesthe half that must be PRESERVED
the six existing #10221 cache casesthe half that must be PRESERVED

The 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.

  • Mutation proven on disk before any result was read, by anchored counts of
    the text actually being changed (not git diff --stat, not an editor exit
    code): narrowed tree 'backendFailed = true' = 1, 'failed = true' = 0;
    ablated tree 'backendFailed = true' = 0, 'failed = true' = 7
    (6 legs + the 1 docblock mention).
  • Restore under trap restore EXIT INT TERM with absolute paths (no
    repo-relative path after a cd), then verified rather than trusted:
    cmp -s reported RESTORE_VERIFIED=byte-identical, the post-restore anchored
    count returned to 1, and git status shows no stray file.
  • Rebuild leg: not applicable here, and that is a property of resolution, not
    of the suite's name.
    The unit pin imports its subject as
    ./resolve-authz-context.js — a relative, intra-package specifier vitest
    resolves to src, never through the package's exportsdist. Where the
    hazard does apply — packages/qa/dogfood, which consumes dist
    deliberately — the artifact was rebuilt and proved before its result was read
    (below).

Measured: Tests 4 failed | 71 passed (75), failing exactly the four predicted
cases. 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 — the
reason this cache was narrowed to failures in the first place) is unmodified
and green. Because that suite resolves @objectstack/core through dist, the
package was rebuilt and the change proved present in the built artifact before
the result was read:

ablation-dist-preflight: @objectstack/core -- expecting "backendFailed" in packages/core/dist
✓ marker present in 2 built files -- the ablation is live in the artifact the suite consumes.
Test Files 1 passed (1)
Tests 2 passed (2)

This change only ever removes cache writes; it adds none. There is no
direction in which it can make a read staler than main already allows.

Verification

Every run below is at 5ce9ca42f4 — the final commit on this branch — and
every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?),
never as $? after a | tail. Verdicts are quoted from what each gate printed
for itself.

Gate union — derived, not recalled.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
against 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:

check:authz-resolver ✓ single shared authorization resolver intact; both entry points delegate.
check:type-check-debt OK — 32 ledger entr(ies) re-measured in 360.1s, 1898 raw tsc error(s)
total, none above its recorded number.
check:cross-package-test-inputs OK: 16 package(s) read outside themselves, all declared, and turbo.json
hashes every declared glob.
check:engine-double-contract 387 (file, verb) row(s) held by the RETAINED ledger (exit 0)
check:where-matcher ✓ 297 matcher(s) discovered, 297 answer the combinator battery correctly
or refuse it loudly; none new.
check:query-options-erasure ✓ ratchet holds: 67 unswept non-test site(s), none new.
baseline key set verified against c804f0c: no files added.
check:slot-lookup ✓ ratchet holds: 107 unswept site(s), none new.
check:nul-bytes OK (scanned 6666 text file(s); no raw ASCII control bytes).
check:kernel-hook-pairs ✓ 4 dispatched kernel:* hook(s), each pinned in both kernel.test.ts and
lite-kernel.test.ts
check:published-files ✓ 69 publishable package(s) … declare a `files` whitelist
check:test-source-alias OK — 72 packages with tests scanned; 61 registered
check:type-source-resolution OK — 93 tsc program(s) across 77 packages scanned
check:type-check-coverage (exit 0)
check:changeset-gate-self-tests ✓ 118 + 212 + 116 assertions over real temp git repos
check:objectui-changeset ✓ objectui-range --self-test: all checks passed
check-adr-0087-registration.mjs ✓ this PR adds no declared-breaking changeset (1 non-breaking seen).
check-changeset-no-major.mjs ✓ This diff introduces no `major` bump.
check-empty-changeset.mjs ✓ No empty-frontmatter changeset introduced by this diff.
check-ci-filter-parity.mjs OK: all 96 declared cross-package glob(s) … covered
check-cross-package-test-inputs.mjs OK: 16 package(s) read outside themselves, all declared
check-plugin-teardown-shape.mjs ✓ 63 Plugin implementation(s) across 4647 source(s); baseline fully
burned down
check-affected-docs.mjs (exit 0)
check-drift-comment.mjs ✓ 56 cases pass across 5 fixture diff(s).
release-rehearsal-clone.mjs --self-test ✓ self-test passed

Suites — the owning package plus every in-repo caller of
resolveLocalizationContext, all exit 0:

@objectstack/core Test Files 38 passed (38) · Tests 949 passed (949)
@objectstack/mcp Test Files 22 passed (22) · Tests 242 passed (242) ← the post-bind re-reader
@objectstack/runtime Test Files 188 passed (188) · Tests 2770 passed (2770)
@objectstack/service-settings Test Files 29 passed (29) · Tests 514 passed (514)
@objectstack/plugin-audit Test Files 21 passed (21) · Tests 321 passed (321)
@objectstack/rest (timezone) Test Files 2 passed (2) · Tests 39 passed (39)
@objectstack/dogfood (pin) Test Files 1 passed (1) · Tests 2 passed (2)

Note: packages/core declares no typecheck script, so none was run for it
— a pnpm --filter @objectstack/core typecheck would have matched zero scripts
and 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-wide eslint . --no-inline-config) was narrowed, and the
narrowing is declared and measured
— three pieces, not an omission:

  1. Population read from eslint's own config, not guessed. Asked eslint
    directly (ESLint#isPathIgnored / calculateConfigForFile) about all three
    changed paths: the two .ts files are governed (ignored=false,
    rules present); the changeset .md is ignored=true by eslint's own config.
    So the eslint-governed population of this diff is exactly 2 files, and the
    narrowed run covered 100% of it.
  2. File count read from --format json, not from my summary:2 results,
    errors=0 warnings=0 on both, exit 0.
  3. Invariance for untouched files: this repo runs one eslint.config.mjs that
    never enables type-aware linting for any file — no parserOptions.project,
    no typed @typescript-eslint rules — stated and positively-controlled in the
    config itself (eslint.config.mjs:326-335). A verdict for an untouched file
    therefore 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

`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
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4f24e9d2e1294de6628d196c4001c71053c11179packageMentionDocs.

Which tree this was computed on

This run read content/docs from 280a026fb9bd57df49b9d0fb697703fe4cdd135c — the merge of head 5ce9ca42f423366e3c5bedd8fe2343575d8148eb into base 4f24e9d2e1294de6628d196c4001c71053c11179, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-warren
os-warren marked this pull request as ready for review August 25, 2026 08:13
@os-warren
os-warren added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit a17da05Aug 25, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-11877-localization-failure-cache-legs branch August 25, 2026 08:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

resolveLocalizationContext's 30s failure cache can answer a deliberate post-bind re-read with the pre-bind value

1 participant

@os-warren