Skip to content

Commit a17da05

Browse files
os-warrenclaude
andauthored
fix(core): only a backend fault populates the localization failure memo (#12044)
`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. Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3b9da58 commit a17da05

3 files changed

Lines changed: 288 additions & 42 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
fix(core): only a backend fault populates `resolveLocalizationContext`'s failure memo (#11877)
6+
7+
`resolveLocalizationContext` memoizes an outcome for 30s whenever the read
8+
"failed" (#10221 — so a repeatedly-failing `sys_setting` query does not re-run,
9+
and the driver does not re-log it, on every request). The write condition was
10+
wider than the cache's own docblock: six legs set the flag and only **one** of
11+
them is the backend fault the docblock describes (the direct `ql.find` throw).
12+
The other five are the **settings service refusing** — a thrown `getMany`, each
13+
of the three older per-key `get`s, and the whole-block "service unavailable"
14+
handler.
15+
16+
Those five legs are reachable inside the settings engine's **bind window**
17+
(`SettingsService.getMany` refuses all-or-nothing for a `localization`
18+
namespace whose manifest is not yet registered), so:
19+
20+
- A caller that deliberately re-reads **after** the bind — the #11580 stdio
21+
repair re-resolves at `kernel:bootstrapped` for exactly this reason — was
22+
answered from the memo taken **inside** the window for up to 30s. The
23+
correction silently did not happen, with nothing in the output saying so.
24+
- A settings refusal standing alongside a perfectly **successful** direct read
25+
memoized that successful value — the staleness the docblock forbids outright
26+
and that `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to catch.
27+
28+
The memo is now written only for the direct-read fault. **#10221's protection
29+
is unchanged for the legs it was built for**: its environment (table not
30+
migrated yet) still memoizes, because the direct read throws there whether or
31+
not a settings refusal stands in front of it — pinned in both directions. And
32+
nothing is lost on the narrowed legs: those refusals throw out of an in-memory
33+
registry check *before* any query and *before* any log line, so memoizing them
34+
suppressed neither.
35+
36+
No signature, export or accepted-input change — the flag is internal to the
37+
module.

packages/core/src/security/resolve-authz-context.test.ts

Lines changed: 203 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,22 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
374374
});
375375
});
376376

377+
// Simulates a fresh environment: `sys_setting` not migrated/written yet, so
378+
// every read rejects the way the real sql-driver's "no such table" does.
379+
// Shared by the #10221 cache block below and the #11877 leg-narrowing block
380+
// after it, which needs the same backend fault standing BEHIND a settings
381+
// refusal.
382+
function makeMissingTableQl() {
383+
const counts = { sys_setting: 0 };
384+
return {
385+
counts,
386+
async find(_object: string) {
387+
counts.sys_setting += 1;
388+
throw new Error('no such table: sys_setting');
389+
},
390+
};
391+
}
392+
377393
// #10221: a fresh environment's `sys_setting` table doesn't exist yet, so
378394
// EVERY request's read used to fail and the sql-driver's `[sql-driver]
379395
// DATABASE_ERROR` warning repeated once per request, burying real errors.
@@ -395,19 +411,6 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
395411
vi.useRealTimers();
396412
});
397413

398-
// Simulates a fresh environment: `sys_setting` not migrated/written yet, so
399-
// every read rejects the way the real sql-driver's "no such table" does.
400-
function makeMissingTableQl() {
401-
const counts = { sys_setting: 0 };
402-
return {
403-
counts,
404-
async find(_object: string) {
405-
counts.sys_setting += 1;
406-
throw new Error('no such table: sys_setting');
407-
},
408-
};
409-
}
410-
411414
it('does not re-query on a second call within the TTL window when the read fails (same tenant)', async () => {
412415
const ql = makeMissingTableQl();
413416
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
@@ -484,6 +487,193 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
484487
});
485488
});
486489

490+
// ── #11877 — a SETTINGS-SERVICE refusal must not populate the failure memo ──
491+
//
492+
// The memo above exists for one thing (#10221): a `sys_setting` query that
493+
// actively FAILS must not re-run — and re-log the driver's line — on every
494+
// request. Its write condition was wider than that. `failed` was set by SIX
495+
// legs and only ONE of them is the backend fault the cache's own docblock
496+
// describes:
497+
//
498+
// settings.getMany(...) threw — the grouped read (settings leg)
499+
// settings.get(...) threw × 3 — the older per-key arm (settings)
500+
// the settings block threw — "service unavailable" (settings)
501+
// ql.find('sys_setting', ...) threw — THE backend fault
502+
//
503+
// The five settings legs are reachable INSIDE the settings engine's bind
504+
// window: `SettingsService.getMany` refuses all-or-nothing for a
505+
// `localization` namespace whose manifest is not (yet) registered. So a
506+
// caller that deliberately re-reads AFTER the bind — the #11580 repair does
507+
// exactly that, re-resolving at `kernel:bootstrapped` — could be answered
508+
// from the memo taken inside the window, within the 30s TTL, with nothing in
509+
// the output saying the correction did not happen.
510+
//
511+
// And directly against this cache's own docblock ("a successful read is NEVER
512+
// cached"): a settings refusal standing alongside a perfectly SUCCESSFUL
513+
// direct read memoized that successful value for 30s — the exact staleness
514+
// `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to forbid.
515+
//
516+
// So the memo is written only for the backend-fault leg now. #10221's
517+
// protection is untouched, and BOTH directions are pinned below: a genuine
518+
// backend fault still memoizes — including with a settings refusal standing
519+
// in front of it — while a settings refusal alone no longer does.
520+
describe('resolveLocalizationContext — only a backend fault populates the memo (#11877)', () => {
521+
beforeEach(() => {
522+
vi.useFakeTimers();
523+
});
524+
afterEach(() => {
525+
vi.useRealTimers();
526+
});
527+
528+
/** What the workspace has PERSISTED — answerable only once the engine binds. */
529+
const CONFIGURED = { timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' };
530+
531+
// The all-or-nothing refusal `SettingsService.getMany` gives for a namespace
532+
// whose manifest is not (yet) registered. It throws out of an in-memory
533+
// registry check — before any query, before any log line — which is why
534+
// memoizing THIS leg never suppressed a query or a log line to begin with.
535+
function makeBindWindowSettings() {
536+
const state = { bound: false, getManyCalls: 0 };
537+
return {
538+
state,
539+
get: async () => {
540+
throw new Error('per-key get must not be called on the batched arm');
541+
},
542+
getMany: async () => {
543+
state.getManyCalls += 1;
544+
if (!state.bound) throw new Error("unknown settings namespace 'localization'");
545+
return {
546+
timezone: { value: CONFIGURED.timezone },
547+
locale: { value: CONFIGURED.locale },
548+
currency: { value: CONFIGURED.currency },
549+
};
550+
},
551+
};
552+
}
553+
554+
// ── the reproduction this card was filed without ────────────────────────
555+
//
556+
// Pre-bind read inside the window (settings refuses, `sys_setting` answers
557+
// an ordinary empty result) → deliberate post-bind re-read 1s later, well
558+
// inside the 30s TTL. The clock is FAKE and advanced explicitly; nothing
559+
// here sleeps on the wall clock.
560+
it('a post-bind re-read inside the TTL is answered by the now-bound service, not by the pre-bind memo', async () => {
561+
const settings = makeBindWindowSettings();
562+
const ql = makeCountingQl({ sys_setting: [] });
563+
564+
const preBind = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
565+
expect(preBind).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined });
566+
expect(settings.state.getManyCalls).toBe(1);
567+
568+
settings.state.bound = true; // the settings engine binds
569+
await vi.advanceTimersByTimeAsync(1_000); // still deep inside the 30s window
570+
571+
const postBind = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
572+
// The re-read must REACH the service (a memo hit would never call it) and
573+
// must carry the configured values, which exist only behind the bind.
574+
expect(settings.state.getManyCalls).toBe(2);
575+
expect(postBind).toEqual({ timezone: 'Asia/Shanghai', locale: 'zh-CN', currency: 'CNY' });
576+
});
577+
578+
// The same refusal, but the direct read SUCCEEDS with rows. The memoized
579+
// value here was a correct, successful answer — frozen for 30s by a leg that
580+
// has nothing to do with the backend.
581+
it('a settings refusal never freezes a SUCCESSFUL direct read: a row change is visible on the very next call', async () => {
582+
const settings = makeBindWindowSettings(); // stays unbound → refuses every call
583+
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }];
584+
const ql = makeCountingQl({ sys_setting: rows });
585+
586+
const first = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
587+
expect(first.timezone).toBe('UTC');
588+
589+
rows[0].value = 'America/Los_Angeles'; // a settings write lands, no TTL advance
590+
const second = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
591+
expect(second.timezone).toBe('America/Los_Angeles');
592+
expect(ql.counts.sys_setting).toBe(2);
593+
});
594+
595+
// The older per-key arm (a service with no `getMany`): three `get` legs,
596+
// same rule.
597+
it('the per-key get legs do not populate the memo either', async () => {
598+
const state = { bound: false, gets: 0 };
599+
const settings = {
600+
get: async (_ns: string, key: string) => {
601+
state.gets += 1;
602+
if (!state.bound) throw new Error("unknown settings namespace 'localization'");
603+
return { value: (CONFIGURED as Record<string, string>)[key] };
604+
},
605+
};
606+
const ql = makeCountingQl({ sys_setting: [] });
607+
608+
expect(await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).toEqual({
609+
timezone: 'UTC',
610+
locale: 'en-US',
611+
currency: undefined,
612+
});
613+
expect(state.gets).toBe(3);
614+
615+
state.bound = true;
616+
await vi.advanceTimersByTimeAsync(1_000);
617+
expect(await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).toEqual({
618+
timezone: 'Asia/Shanghai',
619+
locale: 'zh-CN',
620+
currency: 'CNY',
621+
});
622+
expect(state.gets).toBe(6);
623+
});
624+
625+
// The whole-block leg: a `get` that throws SYNCHRONOUSLY never attaches its
626+
// `.catch`, so the throw escapes `Promise.all` into the outer
627+
// "settings service unavailable → direct read" handler. Same rule.
628+
it('the outer "settings service unavailable" leg does not populate the memo either', async () => {
629+
const state = { bound: false, gets: 0 };
630+
const settings = {
631+
// Deliberately NOT async: this throws before a promise exists.
632+
get: (_ns: string, key: string) => {
633+
state.gets += 1;
634+
if (!state.bound) throw new Error('settings service unavailable');
635+
return Promise.resolve({ value: (CONFIGURED as Record<string, string>)[key] });
636+
},
637+
};
638+
const ql = makeCountingQl({ sys_setting: [] });
639+
640+
expect(await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).toEqual({
641+
timezone: 'UTC',
642+
locale: 'en-US',
643+
currency: undefined,
644+
});
645+
646+
state.bound = true;
647+
await vi.advanceTimersByTimeAsync(1_000);
648+
expect((await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).timezone).toBe('Asia/Shanghai');
649+
});
650+
651+
// ── the half that must be PRESERVED (#10221) ────────────────────────────
652+
//
653+
// Narrowing the write condition must not narrow it to nothing. A settings
654+
// refusal standing in FRONT of a genuinely failing `sys_setting` read is the
655+
// real #10221 environment (fresh deployment: no manifest registered yet AND
656+
// no table yet) — the failing query must still be memoized there.
657+
it('still memoizes when a settings refusal stands in front of a genuine backend fault', async () => {
658+
const settings = makeBindWindowSettings(); // unbound → refuses
659+
const ql = makeMissingTableQl(); // and the direct read throws
660+
661+
await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
662+
expect(ql.counts.sys_setting).toBe(1);
663+
664+
await vi.advanceTimersByTimeAsync(1_000);
665+
await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
666+
// Memo hit: the failing query — and the driver's log line for it — did not
667+
// repeat. The settings refusal is re-attempted (it is free), but that is
668+
// not what #10221 was protecting.
669+
expect(ql.counts.sys_setting).toBe(1);
670+
671+
await vi.advanceTimersByTimeAsync(30_001);
672+
await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
673+
expect(ql.counts.sys_setting).toBe(2); // and it still self-heals on expiry
674+
});
675+
});
676+
487677
describe('grant validity windows (ADR-0091 D1/D2)', () => {
488678
const NOW = Date.parse('2026-07-10T12:00:00Z');
489679
const PAST = '2026-07-01T00:00:00Z';

0 commit comments

Comments
 (0)