Skip to content

Commit 3599fef

Browse files
feat(plugin-audit): AuditPluginOptions.getLocale, a host locale resolver asked before the settings-derived locale (#22324)
Fixes #22318 Clause-②: yes (widening) Executes ruling D as the card quotes it (objectstack-ai/cloud#2435 item 14, director record `6063207364`): `plugin-audit` gains one optional host option, a locale resolver, consulted first and falling back to the settings-derived locale when it answers nothing. Dispatched under claim `6064241770` (session `session_01WkL6Eijt432S1Y7ekb6ovQ`). ## What changes - **`AuditPluginOptions.getLocale`** (`packages/plugins/plugin-audit/src/audit-plugin.ts`), typed `(tenantId?: string, userId?: string) => string | undefined | Promise of (string | undefined)`. The name is the ruling's own spelling and the name of the writer-layer seam it feeds (`installAuditWriters`' `getLocale`). - **The plugin's `getLocale` closure asks it first.** A usable answer is returned; otherwise the closure runs the same `resolveLocalizationContext({ ql, settings, tenantId, userId })` read it ran before. With the option absent, the host step is never built and the closure is the old one, line for line. - **Docs.** The option's TSDoc states the precedence (host first, settings second). The package README (`## Usage`, new subsection "Activity summary language") lists the member with its fallback table. `content/docs/permissions/record-view-auditing.mdx` said "`readAudit` is the only key `AuditPluginOptions` declares", which this change makes false; that sentence now names the second key. The README carried the same sentence and is corrected in the same edit. - **Changeset:** `@objectstack/plugin-audit` `minor`, additive. - Untouched: `audit-writers.ts`, `packages/spec`. No read-time rendering, no per-organization settings, no second locale source. ## Decisions taken in this lane - **What counts as an answer.** `undefined`, `null` and a blank string are "no answer" and fall back silently. A non-empty string is accepted when it is a well-formed BCP-47 tag (`Intl.getCanonicalLocales` accepts it), and is used in canonical form (`zh-cn` becomes `zh-CN`). Anything else (`zh_CN`, a number) falls back to the settings-derived locale and is logged once at `warn`. - Measured: the settings-derived locale gets no support check at this seam either. `coerceLocale` in `packages/core/src/security/resolve-authz-context.ts` only trims it and drops a blank value. Its membership check (the `localization.locale` select options) lives at the settings write door, which a host answer never passes. A membership check against the i18n service's `getLocales()` was weighed and not taken, because providers disagree on what they serve. The memory i18n negotiates `zh` to `zh-CN`, and the file adapter looks locales up exactly, so an exact list would refuse answers a deployment does serve. A locale the i18n service has no catalog for degrades the way a settings-derived one does: to the service's declared fallback, then to the English literal. The locale is never stored on the row; it only picks the catalog. - **A throw falls through, logged once at `warn` with the error.** Measured today: `resolveWriteLocale` (`audit-writers.ts`, the writer's memoized lookup) catches a throwing `getLocale` and answers no locale, so a throw left to propagate would give English summaries for that scope, silently. Catching it in the plugin limits a host fault to the host's half: the settings locale still applies, and the audited write lands. "Once" is per install, because the writer asks per tenant/user scope and a broken resolver would otherwise log once per scope. A rejected promise is handled the same way. ## Tests `packages/plugins/plugin-audit/src/audit-plugin-host-locale.test.ts` (8 cases). They boot the plugin through `init`, `start` and `kernel:ready` with a real memory i18n and a settings occupant answering `ja-JP`, so the host locale, the settings fallback and English are distinguishable. - **Pin:** with `getLocale` answering `zh-CN`, a create writes `sys_activity.summary` = `创建了 Qualification "OC-00001"`, and the resolver is asked with `('org-1', 'user-1')`. - A synchronous ` zh-cn ` answer counts and is canonicalized. - **Fallback:** `undefined`, `null` and a blank string give the settings-derived Japanese summary, with no warning. - **Absent:** no option, and `{}`, give the settings-derived summary. No option and no settings service gives English. - **Throw:** two scopes, two throws, both summaries in the settings locale, both ledger rows written, exactly one `warn` carrying `{ err: 'locale store unreachable' }`. A rejected promise behaves the same way. - **Malformed:** `zh_CN` gives the settings locale and one `warn` naming the answer. Runs at `5fc8c76371`, under `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2`: `Test Files 42 passed (42)`, `Tests 657 passed (657)`. - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, including `check:test-typecheck: OK … 0 file(s) / 0 error(s)`. ## Ablation: the plugin ignores the option Run on committed head `723b1f599e` through `node scripts/ablation-replace.mjs`, which holds the restore on a trap. The anchor `if (readHostLocale) {` (hit once) became `if (readHostLocale && false) {`. - The tool's on-disk proof: `anchor x1 -> x0`, `replace x0 -> x1`, blob `d346f4403902` became `26dbb44c82f3`. - `audit-plugin-host-locale.test.ts`: `Tests 5 failed | 3 passed (8)`. The pin received `Qualification「OC-00001」を作成しました` (the settings locale) where it expected the Chinese summary. The canonical, throw, rejected and malformed cases also went red. The fallback and absent cases stayed green, as expected, because they do not depend on the option. - Restore: `blob after restore d346f44 == blob at HEAD`, `git diff HEAD` empty. An independent `git hash-object` gave the same answer. - No `dist/` step was needed. The test imports `./audit-plugin.js` relatively, so vitest reads the source. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5fc8c76371` (after merging `origin/main`) derived 95 commands. All 95 ran. 92 exited 0 on the first pass. - Three refused at the first pass with `PREREQUISITE NOT MET` (exit 3): `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n`. Each exited 0 after a workspace build. - `--ran`: `✓ dispatch-gates --ran: 95 derived famil(ies) accounted for — 95 run, 0 NOT-MEASURED`. - Roster families whose roster sits under a touched directory, plus two named by the card's kind: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:published-readme-exports` and `check:engine-double-contract`. All exit 0. - **Lint, narrowed and declared:** `eslint --no-inline-config --format json` over the two changed `.ts` files reports 2 files, 0 errors and 0 warnings. A file eslint ignored would show a warning, so 0 warnings means both were linted. - The population read from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, so the `.md`/`.mdx` edits are outside it. - The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on any untouched file. - The repo-wide `pnpm lint` is CI's. ## Acceptance notes - `AuditPluginOptions` is not re-exported from the package index, as before this change. A host can pass an object literal, but can name the type only through `ConstructorParameters` of `AuditPlugin`. Not changed here. Noted, not filed; carrier: none. - `resolveWriteLocale` in `audit-writers.ts` swallows a throwing `getLocale` without a log. The plugin's own closure no longer reaches that path for a host fault, because `resolveLocalizationContext` never throws and the host step catches. A direct caller of the exported `installAuditWriters` still can. Noted, not filed; carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59fb299 commit 3599fef

5 files changed

Lines changed: 393 additions & 6 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
`AuditPluginOptions` gains `getLocale`, an optional host locale resolver asked before the settings-derived locale, so a multi-organization host can write each organization's activity summaries in its language
6+
7+
Clause-②: yes (widening)
8+
9+
- **The option.** `new AuditPlugin({ getLocale })`, where `getLocale` is `(tenantId?: string, userId?: string) => string | undefined | Promise<string | undefined>`. It picks the language of `sys_activity.summary` and of the @mention notification title the plugin emits, and is called with the write's organization and user (for an @mention title, the mentioned recipient).
10+
- **Precedence: host first, settings second.** An answer that is a well-formed BCP-47 tag is used, in its canonical form (`zh-cn` → `zh-CN`). `undefined`, `null` or a blank string falls back to the settings-derived `localization.locale` (ADR-0053) the plugin used before this option. So do a malformed answer (`zh_CN`, a non-string) and a throw or rejected promise; each of those two faults is logged once at `warn`, and neither fails the audited write. There is no other locale source.
11+
- **Without the option, nothing changes.** The settings-derived locale stays the only source, as before.
12+
- The locale only picks the message catalog, and a summary is rendered once, when its row is written. Rows already written keep their text.

‎content/docs/permissions/record-view-auditing.mdx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ await kernel.use(
4242
);
4343
```
4444

45-
`readAudit` is the only key `AuditPluginOptions` declares, and it accepts
46-
exactly those three:
45+
`readAudit` accepts exactly those three:
4746

4847
| Key | Default | Meaning |
4948
| --- | --- | --- |
@@ -59,6 +58,10 @@ object** rather than silently accepted. An empty — or fully excluded — set
5958
registers no hook, so a deployment that opts nothing in pays nothing on its read
6059
path.
6160

61+
`AuditPluginOptions` declares one other key, `getLocale`, a host locale resolver
62+
for the language activity summaries are written in; it does not change what is
63+
audited.
64+
6265
<Callout type="warn">
6366
**There is no object-metadata key and no environment variable.** `enable.auditReads`
6467
does not exist, and neither does a global "audit all reads" flag. The shape is

‎packages/plugins/plugin-audit/README.md‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ await kernel.use(new AuditPlugin());
3737
```
3838

3939
Write coverage is not configured per object — see
40-
[Coverage](#coverage-subtraction-not-an-allow-list). The one thing that *is* configured is
41-
**record-view auditing**, which records nothing until objects are named:
40+
[Coverage](#coverage-subtraction-not-an-allow-list). Of what gets recorded, the one thing that
41+
*is* configured is **record-view auditing**, which records nothing until objects are named:
4242

4343
```typescript
4444
await kernel.use(
@@ -48,8 +48,9 @@ await kernel.use(
4848
);
4949
```
5050

51-
`readAudit` is the only key `AuditPluginOptions` declares, and it accepts exactly three
52-
settings, no others:
51+
`AuditPluginOptions` declares two keys: `readAudit`, and `getLocale` (the language activity
52+
summaries are written in — [below](#activity-summary-language--getlocale)). `readAudit`
53+
accepts exactly three settings, no others:
5354

5455
| Option | Default | Meaning |
5556
|---|---|---|
@@ -61,6 +62,36 @@ settings, no others:
6162
plugin does **not** forward. Setting it requires calling `installReadAuditWriter` directly
6263
against the engine; there is no plugin-level spelling for it.
6364

65+
### Activity summary language — `getLocale`
66+
67+
`sys_activity.summary` and the @mention notification title are written in one language,
68+
chosen per write. Without options it is the deployment's settings-derived
69+
`localization.locale` (ADR-0053). A host that knows better per organization passes a
70+
resolver, which is asked first with the write's `tenantId` and `userId` (for an @mention
71+
title, the mentioned recipient):
72+
73+
```typescript
74+
await kernel.use(
75+
new AuditPlugin({
76+
// (tenantId?: string, userId?: string) => string | undefined | Promise<string | undefined>
77+
getLocale: async (tenantId) => organizationLocale(tenantId),
78+
}),
79+
);
80+
```
81+
82+
| The resolver… | The write's locale |
83+
|---|---|
84+
| answers a well-formed BCP-47 tag | that tag, canonicalized (`zh-cn` → `zh-CN`) |
85+
| answers `undefined`, `null` or a blank string | the settings-derived locale |
86+
| answers a malformed tag (`zh_CN`) or a non-string | the settings-derived locale, logged once at `warn` |
87+
| throws, or its promise rejects | the settings-derived locale, logged once at `warn` with the error; the audited write still lands |
88+
89+
Precedence is host first, settings second, and there is no third source. The writer memoizes
90+
the answer per tenant/user scope for a short TTL, so a resolver may read its own storage
91+
directly. The locale only picks the message catalog: one the i18n service has no catalog for
92+
degrades the way a settings-derived locale does, to the service's declared fallback and then to
93+
the English literal. A summary is rendered once, when the row is written.
94+
6495
The plugin depends on the ObjectQL engine (`com.objectstack.engine.objectql`) and resolves
6596
it at `kernel:ready`. If no engine is available it logs a warning and installs no writers.
6697

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
// STATIC on purpose, like audit-writers.test.ts: the plugin's own kernel:ready
5+
// hook imports the translation bundle lazily, and a static import here moves
6+
// that cold-start cost to collection, which no test's timeout is charged for.
7+
import { createMemoryI18n } from '@objectstack/core';
8+
import { AuditTranslations } from './translations/index.js';
9+
import { AuditPlugin, type AuditPluginOptions } from './audit-plugin.js';
10+
11+
/**
12+
* `AuditPluginOptions.getLocale` — the host locale resolver, driven through the
13+
* PLUGIN (init → start → kernel:ready), not through `installAuditWriters`
14+
* directly: the option is only worth anything if the plugin really hands it to
15+
* the writers ahead of the settings-derived locale it builds itself.
16+
*
17+
* The settings service answers `ja-JP` throughout, so every outcome is
18+
* distinguishable: the host's locale (zh-CN), the settings fallback (ja-JP),
19+
* and the English literal a locale-less write degrades to.
20+
*/
21+
22+
interface CapturedRow {
23+
object: string;
24+
row: Record<string, any>;
25+
}
26+
27+
/** A fake ObjectQL engine: records hooks (honouring `object` / `excludeObjects`) and created rows. */
28+
function makeEngine() {
29+
const hooks = new Map<string, Array<{ fn: (ctx: any) => any; opts: any }>>();
30+
const created: CapturedRow[] = [];
31+
const api = {
32+
sudo: () => ({
33+
object(name: string) {
34+
return {
35+
async create(row: Record<string, any>) {
36+
created.push({ object: name, row });
37+
return { id: `${name}-${created.length}`, ...row };
38+
},
39+
};
40+
},
41+
}),
42+
};
43+
const schemas: Record<string, Record<string, any>> = {
44+
sys_audit_log: { name: 'sys_audit_log', fields: {} },
45+
sys_activity: { name: 'sys_activity', fields: {} },
46+
person_qualification: {
47+
name: 'person_qualification',
48+
label: 'Qualification',
49+
fields: { id: { type: 'text' }, name: { type: 'text' } },
50+
},
51+
};
52+
for (const f of ['id', 'action', 'user_id', 'actor', 'object_name', 'record_id', 'old_value', 'new_value', 'tenant_id']) {
53+
schemas.sys_audit_log.fields[f] = { type: 'text' };
54+
}
55+
for (const f of ['id', 'type', 'timestamp', 'summary', 'actor_id', 'object_name', 'record_id', 'record_label', 'metadata']) {
56+
schemas.sys_activity.fields[f] = { type: 'text' };
57+
}
58+
const engine = {
59+
getSchema(name: string) {
60+
return schemas[name];
61+
},
62+
registerHook(event: string, fn: (ctx: any) => any, opts?: any) {
63+
const list = hooks.get(event) ?? [];
64+
list.push({ fn, opts: opts ?? {} });
65+
hooks.set(event, list);
66+
},
67+
unregisterHooksByPackage() {
68+
/* no-op */
69+
},
70+
};
71+
async function fire(event: string, ctx: any) {
72+
for (const { fn, opts } of hooks.get(event) ?? []) {
73+
if (opts.object && opts.object !== ctx.object) continue;
74+
if (Array.isArray(opts.excludeObjects) && opts.excludeObjects.includes(ctx.object)) continue;
75+
await fn({ ...ctx, event, api });
76+
}
77+
}
78+
return { engine, fire, created };
79+
}
80+
81+
/** A settings occupant whose `localization.locale` is `ja-JP` (the fallback every case can see). */
82+
function makeSettings(locale = 'ja-JP') {
83+
const values: Record<string, unknown> = { locale };
84+
return {
85+
// `resolveLocalizationContext` takes the settings path only when `get` exists, then prefers `getMany`.
86+
async get(_ns: string, key: string) {
87+
return values[key] === undefined ? undefined : { value: values[key] };
88+
},
89+
async getMany(_ns: string, keys: string[]) {
90+
return Object.fromEntries(keys.map((k) => [k, values[k] === undefined ? undefined : { value: values[k] }]));
91+
},
92+
};
93+
}
94+
95+
async function boot(options?: AuditPluginOptions, services: { settings?: unknown } = { settings: makeSettings() }) {
96+
const { engine, fire, created } = makeEngine();
97+
const i18n = createMemoryI18n();
98+
for (const [locale, data] of Object.entries(AuditTranslations)) {
99+
i18n.loadTranslations(locale, data as Record<string, unknown>);
100+
}
101+
const registry = new Map<string, unknown>([
102+
['objectql', engine],
103+
['manifest', { register() {} }],
104+
['i18n', i18n],
105+
]);
106+
if (services.settings !== undefined) registry.set('settings', services.settings);
107+
const readyHooks: Array<() => Promise<void> | void> = [];
108+
const warns: Array<{ msg: string; meta?: Record<string, unknown> }> = [];
109+
const logger = {
110+
info() {},
111+
warn(msg: string, meta?: Record<string, unknown>) {
112+
warns.push({ msg: String(msg), meta });
113+
},
114+
error() {},
115+
debug() {},
116+
child() {
117+
return logger;
118+
},
119+
};
120+
const ctx = {
121+
logger,
122+
getService(name: string) {
123+
return registry.get(name);
124+
},
125+
registerService(name: string, svc: unknown) {
126+
registry.set(name, svc);
127+
},
128+
hook(event: string, fn: () => Promise<void> | void) {
129+
if (event === 'kernel:ready') readyHooks.push(fn);
130+
},
131+
} as any;
132+
const plugin = options === undefined ? new AuditPlugin() : new AuditPlugin(options);
133+
await plugin.init(ctx);
134+
await plugin.start(ctx);
135+
for (const fn of readyHooks) await fn();
136+
137+
const insert = (tenant = 'org-1', user = 'user-1', name = 'OC-00001') =>
138+
fire('afterInsert', {
139+
object: 'person_qualification',
140+
input: { id: `q-${name}` },
141+
result: { id: `q-${name}`, name },
142+
session: { organizationId: tenant, userId: user },
143+
});
144+
const summaries = () => created.filter((c) => c.object === 'sys_activity').map((c) => c.row.summary);
145+
const localeWarns = () => warns.filter((w) => w.msg.includes('getLocale'));
146+
return { insert, summaries, created, localeWarns };
147+
}
148+
149+
const ZH = '创建了 Qualification "OC-00001"';
150+
const JA = 'Qualification「OC-00001」を作成しました';
151+
const EN = 'Created Qualification "OC-00001"';
152+
153+
describe('AuditPlugin — host locale resolver (AuditPluginOptions.getLocale)', () => {
154+
it('pin: a create on a tracked object writes sys_activity.summary in the resolver\'s locale', async () => {
155+
const calls: Array<[string | undefined, string | undefined]> = [];
156+
const { insert, summaries, localeWarns } = await boot({
157+
getLocale: async (tenantId, userId) => {
158+
calls.push([tenantId, userId]);
159+
return 'zh-CN';
160+
},
161+
});
162+
await insert();
163+
expect(summaries()).toEqual([ZH]);
164+
// Asked with the write's organization and user, ahead of the settings read.
165+
expect(calls).toEqual([['org-1', 'user-1']]);
166+
expect(localeWarns()).toEqual([]);
167+
});
168+
169+
it('a synchronous answer counts, and is used in its canonical form', async () => {
170+
const { insert, summaries } = await boot({ getLocale: () => ' zh-cn ' });
171+
await insert();
172+
expect(summaries()).toEqual([ZH]);
173+
});
174+
175+
it('fallback: a resolver answering undefined gives today\'s settings-derived locale', async () => {
176+
const { insert, summaries, localeWarns } = await boot({ getLocale: async () => undefined });
177+
await insert();
178+
expect(summaries()).toEqual([JA]);
179+
expect(localeWarns()).toEqual([]);
180+
});
181+
182+
it('fallback: null and a blank string are "no answer" too, silently', async () => {
183+
const asNull = await boot({ getLocale: (async () => null) as unknown as AuditPluginOptions['getLocale'] });
184+
await asNull.insert();
185+
expect(asNull.summaries()).toEqual([JA]);
186+
expect(asNull.localeWarns()).toEqual([]);
187+
188+
const blank = await boot({ getLocale: () => ' ' });
189+
await blank.insert();
190+
expect(blank.summaries()).toEqual([JA]);
191+
expect(blank.localeWarns()).toEqual([]);
192+
});
193+
194+
it('absent: with no option the summary is the settings-derived one (the existing-behaviour control)', async () => {
195+
const withSettings = await boot();
196+
await withSettings.insert();
197+
expect(withSettings.summaries()).toEqual([JA]);
198+
199+
// An options bag without the member is the same as no bag at all.
200+
const emptyBag = await boot({});
201+
await emptyBag.insert();
202+
expect(emptyBag.summaries()).toEqual([JA]);
203+
204+
// And no settings service either: the built-in en-US default — English.
205+
const bare = await boot(undefined, {});
206+
await bare.insert();
207+
expect(bare.summaries()).toEqual([EN]);
208+
});
209+
210+
it('throw: falls through to the settings-derived locale, logged once at warn with the error, and the write lands', async () => {
211+
let calls = 0;
212+
const { insert, summaries, created, localeWarns } = await boot({
213+
getLocale: () => {
214+
calls += 1;
215+
throw new Error('locale store unreachable');
216+
},
217+
});
218+
await insert('org-1', 'user-1', 'OC-00001');
219+
// A second scope asks again (the writer memoizes per tenant/user) and throws again.
220+
await insert('org-2', 'user-2', 'OC-00002');
221+
expect(calls).toBe(2);
222+
expect(summaries()).toEqual([JA, 'Qualification「OC-00002」を作成しました']);
223+
// The audited write is not failed by the host's fault: both ledger rows landed.
224+
expect(created.filter((c) => c.object === 'sys_audit_log')).toHaveLength(2);
225+
const warned = localeWarns();
226+
expect(warned).toHaveLength(1);
227+
expect(warned[0].msg).toMatch(/host getLocale resolver threw/);
228+
expect(warned[0].meta).toEqual({ err: 'locale store unreachable' });
229+
});
230+
231+
it('throw: a rejected promise falls through the same way', async () => {
232+
const { insert, summaries, localeWarns } = await boot({
233+
getLocale: () => Promise.reject(new Error('timeout')),
234+
});
235+
await insert();
236+
expect(summaries()).toEqual([JA]);
237+
expect(localeWarns()).toHaveLength(1);
238+
expect(localeWarns()[0].meta).toEqual({ err: 'timeout' });
239+
});
240+
241+
it('an answer that is not a well-formed BCP-47 tag falls back, logged once at warn', async () => {
242+
const { insert, summaries, localeWarns } = await boot({ getLocale: () => 'zh_CN' });
243+
await insert('org-1', 'user-1', 'OC-00001');
244+
await insert('org-2', 'user-2', 'OC-00002');
245+
expect(summaries()).toEqual([JA, 'Qualification「OC-00002」を作成しました']);
246+
const warned = localeWarns();
247+
expect(warned).toHaveLength(1);
248+
expect(warned[0].msg).toContain('"zh_CN"');
249+
expect(warned[0].msg).toMatch(/not a well-formed BCP-47 locale tag/);
250+
});
251+
});

0 commit comments

Comments
 (0)