Skip to content

Commit 77e9f79

Browse files
fix(plugin-audit): an activity summary written right after a language change stores the new language (#24261)
Fixes #24235 Clause-②: no The activity summary writer in `plugin-audit` held the workspace locale for each tenant/user scope for a flat 30 s. Nothing retired it when `localization.locale` changed, and a `sys_activity.summary` line is stored once, at write time. So every audited write made in the 30 s after a language change stored the old language permanently. This PR retires the held locale when either settings store is written through the engine. The 30 s TTL stays only as the bound for what that seam cannot see. Responsibility: `plugin-audit`'s own writer (`resolveWriteLocale`'s TTL-only `localeCache`) | the platform path is the core resolver's success cache, retired by the settings generation and the engine write epoch, which the writer's memo sat above and outlived | reached by any workspace that changes `localization.locale` through `PUT /api/settings/localization` and writes within 30 s. ## The defect, measured before the fix A one-off rig (a scratch file, deleted, never committed) at `main` `f5ef054a7`: `ObjectKernel` + `ObjectQLPlugin` + the real `SettingsServicePlugin` + `AuditPlugin` over SQLite, with `Date` frozen. | step | before (`f5ef054a7`) | after (this branch) | |---|---|---| | audited write A, no setting yet | `Created Task "A"`, 2 settings reads | same | | control: audited write B0, no change between | `Created Task "B0"`, **0** settings reads | same, **0** reads | | `settings.set('localization', 'locale', 'zh-CN', tenant ctx)` | reads back `zh-CN`, source `tenant`; `resolveLocalizationContext` answers `zh-CN` | same | | audited write B, right after the change | **`Created Task "B"`** (old language, stored for good) | **`创建了 Task "B"`** | | audited write C, clock moved +31 s | `创建了 Task "C"` | same | ## The PM hypotheses, measured - **Does the engine the audit plugin receives carry the write-epoch seam?** Yes. `ctx.getService('objectql')` is the `ObjectQL` instance (`objectql/src/plugin.ts` registers `this.ql`), and `writeEpoch.current` is a number on it. So the core resolver does cache a success there. - **Can the settings branch read through the core resolver with no memo of its own?** It can, but its cache never hits for this caller. The engine bumps the epoch at the top of every insert, update and delete (`executeWithMiddleware`), and an audited write is one. By the time the writer's after-hook asks, the epoch has always moved. Measured on the same rig: two calls with no write between cost 2, then 0 settings reads; with one audited write between, the next call costs 2 again. Reading through, or retiring the writer's memo on the epoch itself, would add the full settings read (`sys_platform_setting` + `sys_setting`) to every audited write. The memo was written to keep that read off the hot path, and `memoizes the locale lookup per tenant/user scope (hot-path guard)` pins it. **So the suggested route was not taken.** - **The route taken.** The writer keeps one memo and retires it with the writes that can change its answer. These are any `afterInsert` / `afterUpdate` / `afterDelete` on `sys_setting` or `sys_platform_setting`, seen by the writer's own object-scoped hooks. `SettingsService` persists through this engine, so a change through the settings API is one of them. A direct row write, such as a seeder's, is the other, and it emits no settings event at all. These are the same writes that retire the core resolver's cache: its settings generation (the API path) and its epoch backstop (the direct path). One rule decides: an entry stamped with an older generation is dead. The generation is read before the lookup and stamped on its answer, as the core resolver stamps its epoch, so a settings write that lands while a lookup is in flight cannot leave the pre-write answer held. - **The host branch** (`AuditPluginOptions.getLocale` via `createHostLocaleReader`) keeps the TTL-bounded memo. A host resolver offers no seam the writer can see, so the TTL is its only bound, as the card's Wanted says. A settings-store write also retires a held host answer. That costs one extra host call and changes no answer. Host-first precedence is unchanged: `audit-plugin.ts` is not touched. ## What changed - `packages/plugins/plugin-audit/src/audit-writers.ts`: the memo entry carries the generation it was read at. `retireHeldLocales` advances the generation and is registered on the two settings objects for the three after-events, one string-scoped registration each. The handler re-checks `ctx.object`, because a dispatcher that ignored the scope would otherwise retire on every write. The `AuditWriterOptions.getLocale` doc now says when a held answer ends. - `packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts` (new): the card's pin on a real engine. It uses `ObjectKernel` + `ObjectQLPlugin` + `AuditPlugin`, SQLite, and a stand-in `sys_setting` under the real name; no settings service is registered, so the core resolver reads the store directly. `Date` is frozen, so nothing passes on the TTL. The case writes T1 in English and then a control write T2 with **0** `sys_setting` reads. An insert into `sys_setting` follows, and the next write stores `创建了 Task "T3"`. An update follows, and the next write stores `Task「T4」を作成しました`. A last unchanged write is held again, with 0 reads. - `packages/plugins/plugin-audit/src/audit-writers.test.ts`: two writer cases for what the real-engine pin cannot reach. The first covers each store, including `sys_platform_setting`, the global rung, with the no-write control. The second covers a settings write landing while a lookup is in flight. - `.changeset/24235-audit-write-locale-seam.md`: `patch` for `@objectstack/plugin-audit`. ## Contract (Clause-② measured) The built `dist/index.d.ts` of `@objectstack/plugin-audit` was built at `f5ef054a7` and at this branch and then diffed. The only hunk is the JSDoc of `AuditWriterOptions.getLocale`, lines 234–237. No type, signature, export, option key or schema moves. `installAuditWriters`' options type and `AuditPluginOptions` keep their shape. ## Tests and gates (head `dc1de5306`, after merging `origin/main` `8eccf519d`) - `pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2`: 56 files, **894 passed**. - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0. The test layer compiles both edited test files; `--listFiles` names them. - **Ablation** (`node scripts/ablation-replace.mjs`, on the committed fix at `98e0d9fec`): the anchor is `hit.generation === generation &&`, dropped from the read check, which restores the old TTL-only memo. The anchor went x1 to x0 and the blob `f8bbd5f1b148` became `853a4ed4a906`. **3 red, 100 green**: the real-engine pin received `Created Task "T3"` and expected `创建了 Task "T3"`, and both writer cases went red. The restore was proven: blob == HEAD and `git diff HEAD` empty. - **A second ablation, with a finding of its own.** The first version also carried a map clear and a store guard. Dropping the store guard alone stayed **green**, because the read check alone already decides. Both extra guards were removed (`98e0d9fec`), leaving the one rule the ablation above turns red. - The derived gate list (`dispatch-gates.mjs --commands`, 68 commands) was run at this head: **68 of 68 exit 0**. That list is identical to the seat's lead at `f5ef054a7`, and `--ran` reconciles it as 68 derived, 68 run, 0 NOT-MEASURED and 0 UNRUN, a derived zero because every line records its exit code. Among the verdict lines: `check-i18n-bundles: OK (10 package(s) — all bundles in sync`, `check:dual-build-cjs-loads — 111 published require entry point(s) across 69 package(s) load`, `check-engine-double-contract: OK — 1022 pinned`, `check-nul-bytes: OK (scanned 12130 text file(s)` and `check-test-source-alias OK`. On the pre-merge head `98e0d9fec`, `check:i18n` and `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) because their build closure was missing. They passed once the gate's own prerequisite build ran, and both were re-run at `dc1de5306` above. Repo-wide scans (`pnpm lint`) and `test:repo` belong to CI; this diff touches no `test:repo` file. ## Acceptance notes - **Two comments in `packages/core` describe the old staleness as harmless.** `packages/core/src/security/resolve-authz-context.ts:1795`–`:1798` says that for the audit writer "a stale locale in a log line for up to 30s costs nothing observable". `packages/core/src/security/resolve-authz-context.test.ts:422`–`:428` makes the same point ("audit-writer's staleness tolerance"). A summary line is stored once, so the staleness was observable. Both comments also call the writer's memo a TTL-only cache, which is no longer true. `packages/core` is excluded from this card. Carrier: the `domain:engine` seat. - **What the seam does not see.** Two cases stay bounded by the 30 s TTL. The first is a `localization` write on another node: the core resolver gets a remote epoch bump through the `authz.invalidated` bridge, and this writer does not subscribe to the epoch. The second is the storage behind a host `getLocale` resolver. A `SettingsService` with no engine bound writes nothing the writer can see, but no stock composition reaches that: the service refuses writes until it is bound (`engineBindPending`), and the audit writer is not installed without an engine. - **Retirement is coarse.** Any write to either settings store retires every held locale, whatever its namespace. A predicate update names no namespace the hook can read without fetching the row. Settings writes are rare, so the cost is one locale read per scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14a8bd2 commit 77e9f79

4 files changed

Lines changed: 278 additions & 8 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): an activity summary written right after a workspace language change is stored in the new language, not the old one
6+
7+
Clause-②: no (no export, option key, signature or schema changes; the held write locale is retired sooner, and the option docs say when)
8+
9+
- **What was wrong.** The audit writer held the write locale for each tenant/user scope for 30 seconds, and nothing retired it when the `localization.locale` setting changed. A `sys_activity.summary` line is stored once, at write time, so every audited write in that window (a user's create, a seed publish) kept the old language for good.
10+
- **What changes.** A write to `sys_setting` or `sys_platform_setting` through the engine now retires every held write locale. That covers a change made through the settings API, which persists through the engine, and a direct row write such as a seeder's, which emits no settings event. These are the same writes that retire `resolveLocalizationContext`'s own cache. A lookup still in flight when such a write lands is not held. The 30-second bound stays for what the writer cannot see: the storage behind a host `getLocale` resolver, and a settings write on another node.
11+
- **Unchanged:** the host `getLocale` resolver is still asked first, and a write with no settings change between it and the last one is still answered from the held locale, without a settings read.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [#24235] The held write locale against a REAL engine, in its own file: it
4+
// boots a kernel, an engine, a SQLite driver and the audit plugin, and that
5+
// load is kept out of `audit-writers.test.ts`. Static imports, so the load is
6+
// paid during collection, not by one test's timeout.
7+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
8+
import { ObjectKernel } from '@objectstack/core';
9+
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
10+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
11+
import { AuditPlugin } from './audit-plugin.js';
12+
13+
/**
14+
* [#24235] A summary line is stored ONCE, at write time, so the writer's held
15+
* locale must not outlive a language change. Pinned on `ObjectKernel` +
16+
* `ObjectQLPlugin` + `AuditPlugin` over a real SQLite driver: the plugin's own
17+
* locale composition, the core resolver beneath it (which caches here, since
18+
* this engine carries the write epoch) and the engine's real hook dispatch.
19+
*
20+
* No settings service is registered, so the core resolver answers from the
21+
* tenant-scoped `sys_setting` rows directly, and the change is a write to
22+
* that store through the engine — the write `SettingsService` makes when it
23+
* persists, and the one a seeder makes without emitting any settings event.
24+
*
25+
* The clock is frozen from the first audited write on, so no case can pass by
26+
* the TTL running out: what retires the held locale here is the write, or
27+
* nothing does.
28+
*/
29+
describe('audit writers — a localization change reaches the very next audited write (#24235)', () => {
30+
const HARNESS_PACKAGE = 'com.objectstack.audit.test.write-locale-seam';
31+
/** A stand-in under the real name: service-settings owns the definition, and the name is what the seam reads. */
32+
const sysSetting = {
33+
name: 'sys_setting',
34+
label: 'Setting',
35+
isSystem: true,
36+
fields: {
37+
namespace: { name: 'namespace', label: 'Namespace', type: 'text' as const },
38+
key: { name: 'key', label: 'Key', type: 'text' as const },
39+
scope: { name: 'scope', label: 'Scope', type: 'text' as const },
40+
value: { name: 'value', label: 'Value', type: 'text' as const },
41+
},
42+
};
43+
const task = {
44+
name: 'biz_task',
45+
label: 'Task',
46+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
47+
};
48+
49+
let kernel: ObjectKernel;
50+
let engine: ObjectQL;
51+
let sql: (statement: string, bindings: unknown[]) => Promise<Record<string, unknown>[]>;
52+
let settingsReads: () => number;
53+
54+
const SYSTEM = { context: { isSystem: true } };
55+
const asAdmin = { context: { userId: 'u_admin' } };
56+
57+
/** Write one task as a user and answer the summary its activity row stored. */
58+
async function auditedWrite(name: string): Promise<string> {
59+
const row = await engine.insert('biz_task', { name }, asAdmin as any);
60+
const rows = await sql('SELECT summary FROM "sys_activity" WHERE object_name = ? AND record_id = ?', [
61+
'biz_task',
62+
String((row as { id: unknown }).id),
63+
]);
64+
expect(rows).toHaveLength(1);
65+
return String(rows[0].summary);
66+
}
67+
68+
beforeAll(async () => {
69+
kernel = new ObjectKernel({ logger: { level: 'silent' } } as any);
70+
await kernel.use(new ObjectQLPlugin());
71+
await kernel.use(new AuditPlugin());
72+
await kernel.bootstrap();
73+
74+
engine = kernel.getService<ObjectQL>('objectql');
75+
const driver = new SqliteWasmDriver({ filename: ':memory:' });
76+
await driver.connect();
77+
engine.registerDriver(driver, true);
78+
engine.registry.registerObject(sysSetting as any, HARNESS_PACKAGE);
79+
engine.registry.registerObject(task as any, HARNESS_PACKAGE);
80+
await engine.syncSchemas();
81+
82+
sql = async (statement, bindings) => {
83+
const result = await (driver as unknown as { execute(s: string, b: unknown[]): Promise<unknown> }).execute(
84+
statement,
85+
bindings,
86+
);
87+
// knex wraps some results as `[rows]`; normalize both shapes.
88+
const list = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
89+
expect(Array.isArray(list)).toBe(true);
90+
return list as Record<string, unknown>[];
91+
};
92+
const find = vi.spyOn(engine, 'find');
93+
settingsReads = () => find.mock.calls.filter(([object]) => object === 'sys_setting').length;
94+
}, 120_000);
95+
96+
afterAll(async () => {
97+
vi.useRealTimers();
98+
vi.restoreAllMocks();
99+
if (kernel) {
100+
await Promise.race([kernel.shutdown(), new Promise<void>((resolve) => setTimeout(resolve, 10_000))]);
101+
}
102+
}, 30_000);
103+
104+
it('pin: a settings-store write between two audited writes; the second stores the new language', async () => {
105+
// The precondition the whole case leans on: this engine carries the write
106+
// epoch, so the core resolver beneath the writer caches as it does in a
107+
// stock boot rather than declining to.
108+
expect(typeof (engine as unknown as { writeEpoch?: { current?: unknown } }).writeEpoch?.current).toBe('number');
109+
vi.useFakeTimers({ toFake: ['Date'] });
110+
vi.setSystemTime(new Date('2026-10-18T00:00:00Z'));
111+
112+
// No row yet: the built-in default (`en-US`), read once and held.
113+
expect(await auditedWrite('T1')).toBe('Created Task "T1"');
114+
115+
// CONTROL: a write with no settings change between is answered from the
116+
// held locale — not one settings read.
117+
const beforeControl = settingsReads();
118+
expect(await auditedWrite('T2')).toBe('Created Task "T2"');
119+
expect(settingsReads() - beforeControl).toBe(0);
120+
121+
// The change: an insert into the settings store…
122+
await engine.insert(
123+
'sys_setting',
124+
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'zh-CN' },
125+
SYSTEM as any,
126+
);
127+
expect(await auditedWrite('T3')).toBe('创建了 Task "T3"');
128+
129+
// …and an update of it, the shape a settings write takes once the row exists.
130+
await engine.update('sys_setting', { value: 'ja-JP' }, { where: { key: 'locale' }, multi: true, ...SYSTEM } as any);
131+
expect(await auditedWrite('T4')).toBe('Task「T4」を作成しました');
132+
133+
// The new answer is held in turn: the next unchanged write reads nothing.
134+
const beforeHeld = settingsReads();
135+
expect(await auditedWrite('T5')).toBe('Task「T5」を作成しました');
136+
expect(settingsReads() - beforeHeld).toBe(0);
137+
});
138+
});

‎packages/plugins/plugin-audit/src/audit-writers.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,70 @@ describe('audit writers — localized activity summaries (framework#3039)', () =
10871087
expect(localeCalls()).toBe(1);
10881088
});
10891089

1090+
// [#24235] The held locale is retired by a write to either settings store;
1091+
// the real-engine pin is `audit-write-locale-settings-seam.integration.test.ts`.
1092+
// These two hold what it cannot reach: the global rung's own store, and a
1093+
// write landing while a lookup is still in flight.
1094+
const settingsWrite = (object: string) => ({
1095+
object,
1096+
input: {},
1097+
result: { id: 's-1', namespace: 'localization', key: 'locale' },
1098+
previous: { id: 's-1', namespace: 'localization', key: 'locale' },
1099+
session: {},
1100+
});
1101+
const summaryOf = (created: CapturedRow[], name: string) =>
1102+
created.find((c) => c.object === 'sys_activity' && c.row.record_label === name)?.row.summary;
1103+
1104+
it('[#24235] a write to either settings store retires the held locale; without one it is held', async () => {
1105+
for (const store of [undefined, 'sys_setting', 'sys_platform_setting']) {
1106+
const { engine, fire, created } = makeEngine(WITH_QUALIFICATION);
1107+
let locale: string | undefined;
1108+
installAuditWriters(engine as any, 'test.audit', { getI18n: () => makeI18n() as any, getLocale: async () => locale });
1109+
await fire('afterInsert', insertCtx());
1110+
locale = 'zh-CN';
1111+
if (store) await fire('afterUpdate', settingsWrite(store));
1112+
await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } });
1113+
expect(summaryOf(created, 'OC-00001'), String(store)).toBe('Created person_qualification "OC-00001"');
1114+
// The control (`undefined`: no settings write) still answers from the held locale.
1115+
expect(summaryOf(created, 'OC-00002'), String(store)).toBe(
1116+
store ? '创建了 人员资质 "OC-00002"' : 'Created person_qualification "OC-00002"',
1117+
);
1118+
}
1119+
});
1120+
1121+
it('[#24235] a settings write landing while the lookup is in flight leaves the pre-write answer unheld', async () => {
1122+
const { engine, fire, created } = makeEngine(WITH_QUALIFICATION);
1123+
let entered!: () => void;
1124+
const lookupStarted = new Promise<void>((resolve) => {
1125+
entered = resolve;
1126+
});
1127+
let release!: () => void;
1128+
const inFlight = new Promise<void>((resolve) => {
1129+
release = resolve;
1130+
});
1131+
let calls = 0;
1132+
installAuditWriters(engine as any, 'test.audit', {
1133+
getI18n: () => makeI18n() as any,
1134+
getLocale: async () => {
1135+
calls += 1;
1136+
if (calls === 1) {
1137+
entered();
1138+
await inFlight; // read before the write below, answered after it
1139+
return undefined;
1140+
}
1141+
return 'zh-CN';
1142+
},
1143+
});
1144+
const first = fire('afterInsert', insertCtx());
1145+
await lookupStarted;
1146+
await fire('afterUpdate', settingsWrite('sys_setting'));
1147+
release();
1148+
await first;
1149+
await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } });
1150+
expect(summaryOf(created, 'OC-00001')).toBe('Created person_qualification "OC-00001"');
1151+
expect(summaryOf(created, 'OC-00002')).toBe('创建了 人员资质 "OC-00002"');
1152+
});
1153+
10901154
// Collaboration notification titles (assignment / @mention) are localized to
10911155
// the RECIPIENT's locale with the same key shapes, and fall back to the
10921156
// authored object label (not the API name) in English.

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ export interface AuditWriterOptions {
143143
/**
144144
* Resolve the workspace default locale (ADR-0053 `localization.locale`) for
145145
* the write's tenant/user scope. Called per audited write but memoized here
146-
* with a short TTL, so implementations may hit the settings service
147-
* directly. Absent → summaries stay English (status quo).
146+
* per scope, so implementations may hit the settings service directly. A
147+
* held answer lasts until the next write to `sys_setting` or
148+
* `sys_platform_setting` through this engine, or a short TTL, whichever
149+
* comes first. Absent → summaries stay English (status quo).
148150
*/
149151
getLocale?(tenantId?: string, userId?: string): Promise<string | undefined>;
150152
}
@@ -1273,26 +1275,72 @@ export function installAuditWriters(
12731275
}
12741276
};
12751277

1276-
// Workspace locale changes rarely, but writeAudit runs on every CRUD write —
1277-
// memoize the settings lookup per principal scope with a short TTL so audit
1278-
// logging doesn't add a settings query to every mutation's hot path.
1278+
// [#24235] The write locale, held per principal scope. Asking for it costs
1279+
// the settings cascade (one `sys_platform_setting` and one `sys_setting`
1280+
// read on a stock boot) and writeAudit runs on every CRUD write, so the
1281+
// answer is held — but a summary line is stored ONCE, at write time, so a
1282+
// held answer that outlives a language change stores the old language for
1283+
// good. It is therefore held only until the first of:
1284+
//
1285+
// 1. A write to either settings store, seen by this writer's own
1286+
// after-hooks (`retireHeldLocales`, registered below). `SettingsService`
1287+
// persists through this engine, so a `localization` change made through
1288+
// the settings API is one; a direct row write (a seeder) is the other,
1289+
// and it emits no settings event at all. Those are the writes that
1290+
// retire the core resolver's success cache beneath this one
1291+
// (`resolveLocalizationContext`: the settings generation and the engine
1292+
// write epoch), so this memo no longer outlives the layer it sits on.
1293+
// 2. `LOCALE_TTL_MS` — the residual bound, for what (1) cannot see: a host
1294+
// resolver's own storage (`AuditPluginOptions.getLocale` offers no
1295+
// seam), and a peer node's settings write.
1296+
//
1297+
// ⛔ Not the engine write epoch itself, and not a read through the core
1298+
// resolver with no memo here. Every audited write IS an engine write, so the
1299+
// epoch has always moved by the time this writer asks: either shape costs
1300+
// the full settings read on every audited write (measured on a real engine:
1301+
// two settings reads per audited write, against none for a held answer).
12791302
const LOCALE_TTL_MS = 30_000;
1280-
const localeCache = new Map<string, { value: string | undefined; expires: number }>();
1303+
/**
1304+
* Advanced by every settings-store write. ONE rule retires a held answer:
1305+
* an entry stamped with an older generation is dead.
1306+
*/
1307+
let localeGeneration = 0;
1308+
const localeCache = new Map<string, { value: string | undefined; expires: number; generation: number }>();
12811309
const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise<string | undefined> => {
12821310
if (!opts.getLocale) return undefined;
12831311
const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`;
1312+
// Read BEFORE the lookup and stamped on its answer, as the core resolver
1313+
// stamps its epoch: a settings write that lands while the lookup is in
1314+
// flight moves the generation past the stamp, so the pre-write answer is
1315+
// dead on arrival instead of held for the TTL.
1316+
const generation = localeGeneration;
12841317
const now = Date.now();
12851318
const hit = localeCache.get(cacheKey);
1286-
if (hit && hit.expires > now) return hit.value;
1319+
if (hit && hit.generation === generation && hit.expires > now) return hit.value;
12871320
let value: string | undefined;
12881321
try {
12891322
value = await opts.getLocale(tenantId, userId);
12901323
} catch {
12911324
value = undefined;
12921325
}
1293-
localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS });
1326+
localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS, generation });
12941327
return value;
12951328
};
1329+
/**
1330+
* The two objects the settings cascade answers from (ADR-0131 D7: the global
1331+
* rung has its own store). Coarse on purpose — any write to either retires
1332+
* every held locale, whatever its namespace: a predicate update names no
1333+
* namespace the hook can read without fetching the row, and settings writes
1334+
* are rare, so the over-retirement costs one locale read per scope.
1335+
*/
1336+
const LOCALE_SOURCE_OBJECTS: ReadonlySet<string> = new Set(['sys_setting', 'sys_platform_setting']);
1337+
const retireHeldLocales = (ctx: HookContext): void => {
1338+
// Defence in depth, as `writeAudit`'s early return is for its exclusions:
1339+
// the registration is object-scoped, but a dispatcher that ignored the
1340+
// scope would otherwise retire on every write — a memo that never hits.
1341+
if (!LOCALE_SOURCE_OBJECTS.has(ctx.object)) return;
1342+
localeGeneration += 1;
1343+
};
12961344

12971345
// Translate function bound to a locale. Returns undefined on any miss
12981346
// (no locale / no i18n / key miss) so callers keep their English fallback
@@ -1317,6 +1365,15 @@ export function installAuditWriters(
13171365
engine.unregisterHooksByPackage(packageId);
13181366
}
13191367

1368+
// [#24235] The held write locale's retirement seam — see `resolveWriteLocale`.
1369+
// One registration per object, in the string form every registration in this
1370+
// file uses; `retireHeldLocales` re-checks the object either way.
1371+
for (const object of LOCALE_SOURCE_OBJECTS) {
1372+
for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) {
1373+
engine.registerHook(event, retireHeldLocales, { object, packageId });
1374+
}
1375+
}
1376+
13201377
// Whether a given object's *registered* schema declares a field — see
13211378
// `createFieldPresenceProbe` for why the conditional stamp exists. Shared
13221379
// with the auth-event writer so both stamp on the same answer.

0 commit comments

Comments
 (0)