Skip to content

Commit b238856

Browse files
fix(plugin-audit): the activity record label is the record's ADR-0079 title, with the id as the floor (#21896)
Fixes #21878 Clause-②: no ## What changes `plugin-audit`'s record-change mirror wrote each activity row's record label (`record_label`, and the label inside the created / deleted / generic updated summary) from a fixed list of field names (`name`, `subject`, `title`, `full_name`, `label`, `first_name`, `company`, `email`), falling back to the record id. That list was a second display-name dialect beside ADR-0079's `resolveDisplayField`, which the same file already used for referenced-record titles. An object titled by `company_name` therefore showed its record id on every activity row. Now, in `packages/plugins/plugin-audit/src/audit-writers.ts`: - `titleFieldOf(objectName)` is the ONE answer for "which field titles a record of this object in an activity row": `resolveDisplayField` over the registered definition (the cached `getObjectDef` lookup the file already had, no second lookup), flooring to the id when nothing resolves, when the resolver lands on `id`, when the title field is a credential (`collectMaskedReadFields`) or `internal` (`collectInternalReadFields`). Those three skips were inline in `resolveLookupTitles`; they moved here, and `resolveLookupTitles` now asks `titleFieldOf` too. - `recordLabel(record, titleField, id)` reads that field's value from the same masked ledger view as before. A null, structured or blank value floors to the id; it never borrows another populated field. The fixed key list is deleted, not extended. - The `field` answer the activity row declares as the label's source (the declaration the read-side redaction keys on) is the resolved field. No `packages/spec` edit, no change to the read-side gating, no change to the comment hooks, no backfill: rows already written keep their stored label. ## Measured, per the dispatch's hypotheses - **H1, held.** `recordLabel(record, id)` at `audit-writers.ts:299` (base `e085a8c3`) picked from the 8-key list and took no object definition. One caller, the activity mirror. Red pin on a `company_name`-titled object: with the key list put back (ablation below) the new pin reads `expected '3JO4ITH0my2YkCtR' to be 'ARDCOMPANYONE41'` — the card's symptom. - **H2, held.** The one caller already had `getObjectDef` (the cached `engine.getSchema` lookup the referenced-title path and the milestone path use). It is reused; no second lookup. - **H3, measured.** Over the bundled example objects, each registered through a real `ObjectQL` registry so the answer is the materialized `nameField` (registered answer equals authored answer for all 32): 27 keep their label field; 5 change. The "before" column models the list as "first list key the object declares". | object | before | after | |---|---|---| | `crm_contact` | `full_name` (a formula with no `returnType: 'text'`, so ADR-0079 does not derive it) | `first_name`, its registered `nameField` | | `crm_opportunity_line_item` | id | `product` | | `showcase_expense_line` | id | `merchant` | | `showcase_ext_order` | id | `customer_id` | | `showcase_invoice_line` | id | `description` | The same measurement over 50 platform objects (`@objectstack/platform-objects`): 24 move from the id to their declared `nameField`, and 3 change field to it — `sys_email_template` `name` to `label`, `sys_invitation` `email` to `display_title`, `sys_scim_connection_credential` `label` to `connection_id`. In every case the new field is the one the record page titles the record by. Objects titled by `name` / `title` / `subject` are unchanged. - **H4, held.** `activity-field-redaction.ts` `redactActivityRows` serves `record_label` (and the summary that interpolates it) whole only when every declared source field is served to the reader. The declaration now names the resolved field, so a reader not served `company_name` is served neither; pinned in the unit file and at the REST door. - **H5, held.** Nothing rewrites stored rows; only rows written from now on change. ## Tests All at head `e0da4f8a5d`. - `pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2`: 40 files, 641 tests passed. - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, test layer included (`check:test-typecheck` OK, 0 debt). Run at `1088327afd`; `git diff 1088327 e0da4f8 -- packages/plugins/plugin-audit` is empty. - Dogfood, `--project isolated`: the new `activity-label-display-field.dogfood.test.ts` plus the sibling `activity-field-values.dogfood.test.ts`: 2 files, 14 tests passed, against a rebuilt `plugin-audit` `dist/`. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - New unit file `audit-record-label-display-field.test.ts` (real kernel, `ObjectQL`, SQLite driver, `AuditPlugin`; the security service is the one stand-in): `company_name` labels on create / update / delete with `company_name` declared as the source; `name`, `title`, `subject` labelled as before; an explicit `nameField` wins over `name`; a blank title and an empty `name` beside a populated `title` both floor to the id; a credential title floors to the id; a reader not served `company_name` gets neither label nor summary, and the control keeps both. - Fixture triage in `audit-writers.test.ts`: four localized-summary cases wrote a record of `person_qualification`, an object the fake engine never declared, and passed only because the key list guessed `name` from the record. Disposition: declare the object (titled by `name`), since a real engine writes only registered objects. Assertions unchanged. - Narrowed lint: `eslint --no-inline-config --format json` over the four changed TypeScript files: 4 files, 0 errors, 0 warnings. All four sit inside the config's linted population (`--print-config` resolves each). `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. `pnpm lint` itself is CI's. ## Ablation: the fixed key list put back Through `scripts/ablation-replace.mjs` (anchor hit 1, blob `47497d1d8eed` to `61d82281885f`), committed fix first. - **Unit, source-resolved:** 7 red, 4 green, as predicted. Red: both `company_name` label cases, the explicit `nameField` case, the empty-`name` floor, the credential floor, and both read-side cases (the label at rest was the id). Green, as predicted: `name` / `title` / `subject`, the blank-`company_name` floor, the registry control. Restore: blob after restore `47497d1d8eed` equals HEAD, `git diff HEAD` empty. - **Dogfood, dist-resolved:** mutate, rebuild `plugin-audit`, `ablation-dist-preflight` found the marker in both JS bundles, then 2 red (the `company_name` label case; the withheld reader was served a `record_label`) and 2 green (the `name` case, the empty-title floor). Restore: source blob equals HEAD, rebuilt, `ablation-dist-preflight --absent` exit 0 (marker absent from all 6 built files, tree clean), then the green dogfood run above. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `e0da4f8a5d`: 70 commands, all run, and the `--ran` reconciliation reads 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. Two first readings were build-state, not this diff: `check:dts-closure` exited 1 on `@objectstack/hono`'s declarations while my own closure build was rewriting that `dist/` (re-run: 0, 169/169 present), and `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET, on packages this worktree had not built yet (re-run after the full build: 0). ## Acceptance notes - `crm_contact`'s activity label moves from the full name to the first name, because the example's `full_name` formula declares no `returnType: 'text'` and its registered title is `first_name`. The activity label now agrees with the record page. Whether the example should title contacts by `full_name` is the example's call. Carrier: none. - A formula title (`display_title` on several platform objects) is declared as the label's only source, though its value is computed from other fields. A reader served the formula field is served the same value by the data plane, so the label serves nothing the data plane does not. - The writer's per-installation object-definition cache (`getObjectDef`, `getFieldDefs`) is never invalidated, so a definition changed at runtime may be read stale by the label, milestones and `enable.activities` alike. This was read from the source, not measured, and it predates this change. Carrier: none. - `origin/main` advanced 4 commits past the base `e085a8c3`; none touches a file of this diff or what the gate derivation reads. Not merged; the queue rebuilds on current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 25eb7de commit b238856

5 files changed

Lines changed: 587 additions & 25 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
An activity row names its record by the record's title as every renderer resolves it, not by a guess from a fixed list of field names
6+
7+
Clause-②: no
8+
9+
The record-change mirror writes a label for the record into each `sys_activity` row (`record_label`, and inside the created, deleted and generic updated summary). It used to pick that label from a fixed list of field names (`name`, `subject`, `title`, `full_name`, `label`, `first_name`, `company`, `email`) and fall back to the record id. An object titled by any other field showed its record id on every activity row: an object titled by `company_name` read `Created Customer "RECORD_ID"`, with the raw record id, while its record page showed the company name.
10+
11+
The label is now the value of the object's title field as ADR-0079 resolves it (`nameField`, then the deprecated `displayNameField`, then the same derivation the record page, the picker and the approvals inbox use). The record id stays the floor: when nothing resolves, when the title field is the primary key, a credential or a field declared `internal`, or when the record's title value is empty. An empty title no longer borrows another populated field.
12+
13+
- Objects titled by `name`, `title` or `subject` are labelled as before.
14+
- An object whose `nameField` names another field is now labelled by that field. An object whose only list match was not its resolved title is labelled by its title now; in the bundled examples that moves the CRM contact from `full_name` (a formula that declares no `returnType: 'text'`, so it is not derived as the title) to `first_name`, its registered title.
15+
- The activity row records the resolved field as the label's source, so the read-side redaction keeps serving the label only to a reader served that field.
16+
17+
Rows written before this change keep the label they were written with; nothing is backfilled.
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21878] The activity row's record label is the record's TITLE as ADR-0079
5+
* resolves it, with the record id as the floor.
6+
*
7+
* The CRUD mirror (`audit-writers.ts`) names the record in every activity row
8+
* it writes: in `record_label`, and inside the created / deleted / generic
9+
* updated summary. Which field that name is read from is ADR-0079's
10+
* `resolveDisplayField` over the registered definition — the answer every
11+
* renderer titles the record by — so an object titled by a field no fixed key
12+
* list anticipated (`company_name`) is named by its title rather than its id.
13+
*
14+
* What is pinned:
15+
* - an object titled only by `company_name` is labelled by it, on the create,
16+
* update and delete rows, and the row declares `company_name` as the
17+
* label's source (the declaration #21081's read side redacts by);
18+
* - objects titled by `name` / `title` / `subject` are labelled by that field;
19+
* - an explicit `nameField` decides the label over a `name` field;
20+
* - an empty title value floors to the id, never to another populated field;
21+
* - a title field that is a credential floors to the id;
22+
* - #21081's masking still holds: a reader not served the resolved title
23+
* field is served neither the label nor the summary that carries it, while
24+
* a reader served it keeps both.
25+
*
26+
* Real kernel, real engine, real driver, the real `AuditPlugin`. The one
27+
* stand-in is the security service, answering the two contract members the
28+
* read side asks (`activity-field-redaction.test.ts` has the same double and
29+
* says why).
30+
*
31+
* ⚠️ No test title states a value.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { ObjectKernel } from '@objectstack/core';
36+
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
37+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
38+
import type { EngineQueryOptions } from '@objectstack/spec/data';
39+
40+
import { AuditPlugin } from './audit-plugin.js';
41+
42+
const ACTIVITY = 'sys_activity';
43+
const HARNESS_PACKAGE = 'com.objectstack.audit.test.record-label-display-field';
44+
const SYS = { isSystem: true } as const;
45+
/** Not served the company object's title field. */
46+
const UNSERVED_READER = { userId: 'u_unserved', tenantId: 'org_1', positions: ['org_member'] };
47+
/** Served every field: the control. */
48+
const CONTROL = { userId: 'u_control', tenantId: 'org_1', positions: ['org_member'] };
49+
50+
const OBJ = {
51+
company: 'ard_company',
52+
named: 'ard_named',
53+
titled: 'ard_titled',
54+
subjected: 'ard_subjected',
55+
pointer: 'ard_pointer',
56+
credential: 'ard_credential',
57+
} as const;
58+
59+
/** Synthetic values. */
60+
const V = {
61+
company: 'ARDCOMPANYONE41',
62+
companyRenamed: 'ARDCOMPANYTWO42',
63+
phone: '+1-555-0100',
64+
name: 'ARDNAME43',
65+
nameTitle: 'ARDNAMETITLE44',
66+
title: 'ARDTITLE45',
67+
subject: 'ARDSUBJECT46',
68+
code: 'ARDCODE47',
69+
pointerName: 'ARDPOINTERNAME48',
70+
emptyNameTitle: 'ARDEMPTYNAMETITLE49',
71+
secret: 'ARDSECRET50',
72+
credentialName: 'ARDCREDNAME51',
73+
};
74+
75+
const text = (name: string) => ({ name, label: name, type: 'text' as const });
76+
77+
const objects = [
78+
{
79+
// The measured case: no `name` field and no `nameField`.
80+
name: OBJ.company,
81+
label: 'Label Company',
82+
fields: {
83+
company_name: text('company_name'),
84+
phone: { name: 'phone', label: 'Phone', type: 'phone' as const },
85+
status: {
86+
name: 'status', label: 'Status', type: 'select' as const,
87+
options: [{ label: 'Active', value: 'active' }, { label: 'Lost', value: 'lost' }],
88+
},
89+
},
90+
},
91+
{ name: OBJ.named, label: 'Label Named', fields: { name: text('name'), title: text('title') } },
92+
{ name: OBJ.titled, label: 'Label Titled', fields: { title: text('title'), notes: { name: 'notes', label: 'Notes', type: 'textarea' as const } } },
93+
{ name: OBJ.subjected, label: 'Label Subjected', fields: { subject: text('subject'), body: { name: 'body', label: 'Body', type: 'textarea' as const } } },
94+
{ name: OBJ.pointer, label: 'Label Pointer', nameField: 'code', fields: { name: text('name'), code: text('code') } },
95+
{
96+
name: OBJ.credential,
97+
label: 'Label Credential',
98+
nameField: 'pass_code',
99+
fields: { name: text('name'), pass_code: { name: 'pass_code', label: 'Pass Code', type: 'password' as const } },
100+
},
101+
];
102+
103+
type Row = Record<string, any>;
104+
105+
describe('[#21878] the activity record label is the ADR-0079 title, with the id as the floor', () => {
106+
let kernel: ObjectKernel;
107+
let engine: ObjectQL;
108+
const ids: Record<string, string> = {};
109+
110+
/** All fields of `object`, as the engine registry holds them. */
111+
const allFields = (object: string): string[] =>
112+
Object.keys(((engine as any).getSchema(object)?.fields ?? {}) as Record<string, unknown>);
113+
114+
/** The double: the two contract members the read side asks, per reader. */
115+
const security = {
116+
async getReadableFields(object: string, context?: any): Promise<string[] | undefined> {
117+
const all = allFields(object);
118+
if (context?.userId === UNSERVED_READER.userId && object === OBJ.company) {
119+
return all.filter((f) => f !== 'company_name');
120+
}
121+
return all;
122+
},
123+
async getQueryableFields(object: string, context?: any): Promise<string[] | undefined> {
124+
return security.getReadableFields(object, context);
125+
},
126+
};
127+
128+
/** The mirror rows about one record, oldest first. */
129+
const rowsAbout = (object: string, id: string, context: EngineQueryOptions['context'] = SYS) =>
130+
engine.find(ACTIVITY, {
131+
where: { object_name: object, record_id: id },
132+
orderBy: [{ field: 'timestamp', order: 'asc' }],
133+
context,
134+
}) as Promise<Row[]>;
135+
136+
const rowOfType = async (object: string, id: string, type: string): Promise<Row> => {
137+
const row = (await rowsAbout(object, id)).find((r) => r.type === type);
138+
expect(row, `a ${type} row about ${object}`).toBeTruthy();
139+
return row!;
140+
};
141+
142+
/** The fields the writer declared the label was composed from. */
143+
const labelSources = (row: Row): unknown =>
144+
(typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata)?.text_sources?.record_label;
145+
146+
beforeAll(async () => {
147+
kernel = new ObjectKernel({ logger: { level: 'silent' } });
148+
await kernel.use(new ObjectQLPlugin());
149+
await kernel.use({
150+
name: 'test.security-double',
151+
version: '0.0.0',
152+
init: async (ctx: any) => ctx.registerService('security', security),
153+
start: async () => {},
154+
} as any);
155+
await kernel.use(new AuditPlugin());
156+
await kernel.bootstrap();
157+
158+
engine = kernel.getService<ObjectQL>('objectql');
159+
const driver = new SqliteWasmDriver({ filename: ':memory:' });
160+
await driver.connect();
161+
engine.registerDriver(driver, true);
162+
for (const o of objects) engine.registry.registerObject(o as any, HARNESS_PACKAGE);
163+
await engine.syncSchemas();
164+
165+
const insert = async (object: string, data: Row) => String((await engine.insert(object, data, { context: SYS })).id);
166+
167+
// The company record lives through all three verbs.
168+
ids.company = await insert(OBJ.company, { company_name: V.company, phone: V.phone, status: 'active' });
169+
await engine.update(OBJ.company, { company_name: V.companyRenamed }, { where: { id: ids.company }, context: SYS });
170+
await engine.delete(OBJ.company, { where: { id: ids.company }, context: SYS });
171+
// A company record kept alive for the read-side cases.
172+
ids.companyKept = await insert(OBJ.company, { company_name: V.company, status: 'active' });
173+
ids.companyEmpty = await insert(OBJ.company, { company_name: ' ', phone: V.phone, status: 'lost' });
174+
ids.named = await insert(OBJ.named, { name: V.name, title: V.nameTitle });
175+
ids.namedEmpty = await insert(OBJ.named, { name: '', title: V.emptyNameTitle });
176+
ids.titled = await insert(OBJ.titled, { title: V.title });
177+
ids.subjected = await insert(OBJ.subjected, { subject: V.subject });
178+
ids.pointer = await insert(OBJ.pointer, { name: V.pointerName, code: V.code });
179+
ids.credential = await insert(OBJ.credential, { name: V.credentialName, pass_code: V.secret });
180+
}, 120_000);
181+
182+
afterAll(async () => {
183+
if (kernel) {
184+
await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 10_000))]);
185+
}
186+
}, 30_000);
187+
188+
// ── acceptance 1: an object titled by `company_name` ───────────────────
189+
190+
it('control: the registry resolves the company object to company_name, with no nameField authored', () => {
191+
expect((engine as any).getSchema(OBJ.company)?.nameField).toBe('company_name');
192+
});
193+
194+
it('a company_name-titled object: the create row is labelled by the title and declares it as the source', async () => {
195+
const row = await rowOfType(OBJ.company, ids.company, 'created');
196+
expect(row.record_label).toBe(V.company);
197+
expect(labelSources(row)).toEqual(['company_name']);
198+
expect(String(row.summary)).toContain(V.company);
199+
expect(String(row.summary)).not.toContain(ids.company);
200+
});
201+
202+
it('a company_name-titled object: the update and delete rows are labelled by the title too', async () => {
203+
const updated = await rowOfType(OBJ.company, ids.company, 'updated');
204+
expect(updated.record_label).toBe(V.companyRenamed);
205+
expect(labelSources(updated)).toEqual(['company_name']);
206+
const deleted = await rowOfType(OBJ.company, ids.company, 'deleted');
207+
expect(deleted.record_label).toBe(V.companyRenamed);
208+
expect(labelSources(deleted)).toEqual(['company_name']);
209+
expect(String(deleted.summary)).toContain(V.companyRenamed);
210+
});
211+
212+
// ── acceptance 2: `name` / `title` / `subject` are labelled as before ──
213+
214+
it('an object with name (and title) is labelled by name', async () => {
215+
const row = await rowOfType(OBJ.named, ids.named, 'created');
216+
expect(row.record_label).toBe(V.name);
217+
expect(labelSources(row)).toEqual(['name']);
218+
});
219+
220+
it('an object with title is labelled by title, and one with subject by subject', async () => {
221+
const titled = await rowOfType(OBJ.titled, ids.titled, 'created');
222+
expect(titled.record_label).toBe(V.title);
223+
expect(labelSources(titled)).toEqual(['title']);
224+
const subjected = await rowOfType(OBJ.subjected, ids.subjected, 'created');
225+
expect(subjected.record_label).toBe(V.subject);
226+
expect(labelSources(subjected)).toEqual(['subject']);
227+
});
228+
229+
it('an explicit nameField decides the label over a name field', async () => {
230+
const row = await rowOfType(OBJ.pointer, ids.pointer, 'created');
231+
expect(row.record_label).toBe(V.code);
232+
expect(labelSources(row)).toEqual(['code']);
233+
});
234+
235+
// ── acceptance 3: an empty title value floors to the id ────────────────
236+
237+
it('an empty (blank) title value floors to the id, declaring no source field', async () => {
238+
const row = await rowOfType(OBJ.company, ids.companyEmpty, 'created');
239+
expect(row.record_label).toBe(ids.companyEmpty);
240+
expect(labelSources(row)).toEqual([]);
241+
});
242+
243+
it('an empty title floors to the id, never to another populated field', async () => {
244+
const row = await rowOfType(OBJ.named, ids.namedEmpty, 'created');
245+
expect(row.record_label).toBe(ids.namedEmpty);
246+
expect(labelSources(row)).toEqual([]);
247+
expect(JSON.stringify([row.record_label, row.summary])).not.toContain(V.emptyNameTitle);
248+
});
249+
250+
it('a credential title field floors to the id: no credential value and no mask reaches the label', async () => {
251+
const row = await rowOfType(OBJ.credential, ids.credential, 'created');
252+
expect(row.record_label).toBe(ids.credential);
253+
expect(labelSources(row)).toEqual([]);
254+
expect(JSON.stringify([row.record_label, row.summary])).not.toContain(V.secret);
255+
});
256+
257+
// ── acceptance 4: #21081's masking still holds on the resolved field ──
258+
259+
it('a reader not served the resolved title field is served neither the label nor the summary carrying it', async () => {
260+
const atRest = await rowOfType(OBJ.company, ids.companyKept, 'created');
261+
expect(atRest.record_label).toBe(V.company);
262+
const rows = await rowsAbout(OBJ.company, ids.companyKept, UNSERVED_READER);
263+
expect(rows).toHaveLength(1);
264+
expect(rows[0]).not.toHaveProperty('record_label');
265+
expect(rows[0]).not.toHaveProperty('summary');
266+
expect(JSON.stringify(rows)).not.toContain(V.company);
267+
});
268+
269+
it('control: a reader served the title field keeps the label and the summary', async () => {
270+
const rows = await rowsAbout(OBJ.company, ids.companyKept, CONTROL);
271+
expect(rows).toHaveLength(1);
272+
expect(rows[0]).toHaveProperty('record_label', V.company);
273+
expect(String(rows[0].summary)).toContain(V.company);
274+
});
275+
});

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -897,11 +897,16 @@ describe('audit writers — localized activity summaries (framework#3039)', () =
897897
return i18n;
898898
}
899899

900+
// [#21878] The object the records below belong to is DECLARED, titled by
901+
// `name`: the label is read from the object's ADR-0079 title field, and a
902+
// real engine writes only objects its registry holds.
903+
const WITH_QUALIFICATION = { ...SINGLE_TENANT, person_qualification: ['id', 'name'] };
904+
900905
function setup(
901906
locale: string | undefined,
902907
i18n?: { t: Function },
903908
objectDefs: Record<string, any> = {},
904-
schemas: Record<string, string[] | Record<string, any>> = SINGLE_TENANT,
909+
schemas: Record<string, string[] | Record<string, any>> = WITH_QUALIFICATION,
905910
) {
906911
const { engine, fire, created } = makeEngine(schemas, objectDefs);
907912
let localeCalls = 0;
@@ -965,7 +970,7 @@ describe('audit writers — localized activity summaries (framework#3039)', () =
965970
});
966971

967972
it('keeps English summaries without a locale resolver (status quo)', async () => {
968-
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
973+
const { engine, fire, created } = makeEngine(WITH_QUALIFICATION);
969974
installAuditWriters(engine as any, 'test.audit', { getI18n: () => undefined });
970975
await fire('afterInsert', insertCtx());
971976
expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe(

0 commit comments

Comments
 (0)