Skip to content

Commit ef1fc78

Browse files
fix(plugin-audit): the generic activity entry for an update names the fields that changed (#25958)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DUJM4Nrn5kmLCwNBJDQeHpe)_ Fixes #25787 Clause-②: no Metadata-protocol: none ## What changed The activity mirror in `plugin-audit` (`audit-writers.ts`, ADR-0052 §5b) writes an update entry in one of three shapes: a fired milestone's sentence, a `trackHistory` diff (`Stage: Proposal → Closed Won`), or a generic fallback. The fallback said `Updated OBJECT "LABEL"` and nothing else. On HotCRM 18.1.0 the campaign roll-up counters (`num_sent`, `num_responses`, … — plain `number` fields, none tracked, bumped once per enrolled member by `campaign_member.hook.ts`) produced thirteen identical entries in a row. The fallback now names the changed fields by label, in declaration order: `Updated Campaign "CPG-0001": Number Sent, Responses, and Leads` - **Labels only, never values.** `trackHistory` still decides whether old → new VALUES are rendered; an untracked field's value (or a credential's) never reaches the text. The ADR-0052 §5b.1 opt-in is unchanged. - **Bounded.** At most five fields are named and the rest counted (`…, Field 5, and 3 more`). When `sys_activity.summary` declares `maxLength` (500 today; the SQL driver sizes the column `varchar(500)` from it, so an over-long text fails the whole activity insert), fewer fields are named until the text fits; if not even one name fits, the field-less frame is written as before. - **Redaction still applies.** Every named or counted field is recorded in the row's summary-source declaration (`text_sources.summary`), so `activity-field-redaction.ts` drops the summary for a reader not served one of them, exactly as for a tracked-change summary. - **Localized.** Two message keys in the plugin's own en / zh-CN / ja-JP / es-ES bundles (`translations/messages.ts`): `messages.activityUpdatedFields` (frame with `{{fields}}`) and `messages.activityMoreFields` (`{{count}} more`). The list is joined with `Intl.ListFormat` in the frame's locale (zh-CN: `F1、F2、F3、F4、F5和另外 2 个字段`). A locale bundle that has only the older `messages.activityUpdated` (an app-shipped locale) keeps its own field-less sentence rather than an English one. - Unchanged: which rows are written (no input refused, no event dropped), the milestone and tracked branches, create/delete summaries, `metadata.old/new`, `changed_fields`. Folding consecutive same-actor entries is the renderer's half (objectui) and is not in this PR; #25787's triage routes it to its own card. ## Evidence - Red first: the new and updated cases in `audit-writers.test.ts` run against `origin/main` `2a728ba2`'s `audit-writers.ts` + `messages.ts` (restored into the tree, then `git checkout HEAD --` back; `git diff HEAD` 0 bytes after): `Tests 8 failed | 108 passed (116)`. With the fix: all pass. - `pnpm --filter @objectstack/plugin-audit test`: `Test Files 60 passed (60)`, `Tests 993 passed (993)` (before the last two commits; `audit-writers.test.ts` re-run at head: 117 passed). - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0. - Pins: declaration order; no value (untracked or `secret`) in the text; source declaration; five-name cap with count; 40 long labels under a declared 500 bound (length at most 500, count still present); field-less frame when nothing fits; zh-CN list; an app locale with only `activityUpdated` keeps its sentence. `audit-milestone-summary.test.ts` and `audit-option-label-summary.test.ts` pass unchanged. ## Gates (local subset, at head `cebc2d01`) - `pnpm --filter @objectstack/plugin-audit test` at `cebc2d01`: `Test Files 60 passed (60)`, `Tests 993 passed (993)`; `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` (72 families, merge base `2a728ba2a`), reconciled with `--ran`: 71 run, all exit 0; 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, needs a whole-tree build; CI's lint lane owns it). `check:i18n` was run after its declared CLI closure build (10 packages in sync, `plugins/plugin-audit` 7 bundles); `check-plugin-teardown-shape --self-test` after fetching its pinned fixture commit. - `check:commit-card-trailers --range origin/main..HEAD`: 4 commits clean. `check:nul-bytes`: exit 0. - `origin/main` moved 5 commits since the base; none touches `packages/plugins/plugin-audit`, so no merge was taken. - CI's own families are read from this PR's check-runs. ## Acceptance notes - An update that changes only computed fields (`formula`, `summary`, `rollup`, `autonumber`) writes no activity row at all: `diff()` drops those types and the writer returns on an empty diff. Unchanged here, noted by the seat on the claim. - The 500 bound is guaranteed for the field list this PR adds. A record label or object label that alone exceeds it overflows every branch (create, delete, generic), and a `trackHistory` diff of long values is unbounded too; both predate this PR. Not measured against a SQL driver here. - A changed key the object does not declare is named by its machine key (the tracked branch's existing label fallback). --- _Generated by [Claude Code](https://claude.ai/code/session_0166WnEHW3hURHjBf9cLK32f)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c37228 commit ef1fc78

4 files changed

Lines changed: 324 additions & 11 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': patch
3+
---
4+
5+
fix(plugin-audit): an activity entry for an update that moves no `trackHistory` field names the fields that changed
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The record Discussion feed wrote `Updated <object> "<label>"` for every update that fired no milestone and changed no `trackHistory` field, so a record a roll-up, flow or seed updates repeatedly showed a run of identical entries with nothing to tell them apart. That entry now names the changed fields by label, in declaration order: `Updated Campaign "CPG-0001": Number Sent, Responses, and Leads`. Values are still rendered only for `trackHistory` fields; an untracked field's value never reaches the text.
11+
12+
At most five fields are named and the rest counted (`…, and 3 more`), and fewer are named when the text would exceed the `maxLength` declared on `sys_activity.summary`. Each named or counted field is recorded as a source of the summary, so a reader not served one of those fields is served the entry without its summary, as for a tracked-change entry. Two message keys are added to the plugin's en, zh-CN, ja-JP and es-ES bundles, `messages.activityUpdatedFields` and `messages.activityMoreFields`. A locale bundle that defines `messages.activityUpdated` but not `messages.activityUpdatedFields` keeps its field-less sentence.

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

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)',
562562
expect(activity?.row.summary).toBe('Stage: Proposal → Closed Won');
563563
});
564564

565-
it('falls back to the generic summary when only untracked fields change', async () => {
565+
it('falls back to the generic summary, naming the untracked field that changed', async () => {
566566
const { engine, fire, created } = makeEngine(SCHEMA);
567567
installAuditWriters(engine as any, 'test.audit');
568568

@@ -575,7 +575,129 @@ describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)',
575575
});
576576

577577
const activity = created.find((c) => c.object === 'sys_activity');
578-
expect(activity?.row.summary).toBe('Updated crm_opportunity "Acme Renewal"');
578+
// The label, never the value: `amount` is not `trackHistory`'d.
579+
expect(activity?.row.summary).toBe('Updated crm_opportunity "Acme Renewal": Amount');
580+
});
581+
});
582+
583+
describe('audit writers — the generic update summary names the changed fields (#25787)', () => {
584+
// A campaign whose roll-up counters a hook bumps once per enrolled member —
585+
// plain number fields, none `trackHistory`'d (the measured HotCRM shape).
586+
const CAMPAIGN_FIELDS: Record<string, any> = {
587+
id: { type: 'text' },
588+
name: { type: 'text', label: 'Name' },
589+
num_sent: { type: 'number', label: 'Number Sent' },
590+
num_responses: { type: 'number', label: 'Responses' },
591+
num_leads: { type: 'number', label: 'Leads' },
592+
budget_code: { type: 'text', label: 'Budget Code' },
593+
api_token: { type: 'secret', label: 'API Token' },
594+
};
595+
const schemaWith = (fields: Record<string, any>, summary: Record<string, any> = { type: 'text' }) => ({
596+
sys_audit_log: SINGLE_TENANT.sys_audit_log,
597+
sys_activity: {
598+
...Object.fromEntries(SINGLE_TENANT.sys_activity.map((f) => [f, { type: 'text' }])),
599+
summary,
600+
},
601+
campaign: fields,
602+
});
603+
604+
async function updateCampaign(
605+
schema: Record<string, any>,
606+
previous: Record<string, any>,
607+
result: Record<string, any>,
608+
) {
609+
const { engine, fire, created } = makeEngine(schema, { campaign: { label: 'Campaign' } });
610+
installAuditWriters(engine as any, 'test.audit');
611+
await fire('afterUpdate', {
612+
object: 'campaign',
613+
input: { id: 'cpg-1' },
614+
result: { id: 'cpg-1', name: 'CPG-0001', ...result },
615+
previous: { id: 'cpg-1', name: 'CPG-0001', ...previous },
616+
session: {},
617+
});
618+
const activity = created.find((c) => c.object === 'sys_activity');
619+
expect(activity).toBeDefined();
620+
return { summary: activity!.row.summary as string, metadata: JSON.parse(activity!.row.metadata) };
621+
}
622+
623+
it('names every changed field, in declaration order, as a list', async () => {
624+
const { summary } = await updateCampaign(
625+
schemaWith(CAMPAIGN_FIELDS),
626+
{ num_leads: 3, num_responses: 1, num_sent: 12 },
627+
{ num_leads: 4, num_responses: 2, num_sent: 13 },
628+
);
629+
expect(summary).toBe('Updated Campaign "CPG-0001": Number Sent, Responses, and Leads');
630+
});
631+
632+
it('never carries a value: neither an untracked one nor a credential', async () => {
633+
const { summary } = await updateCampaign(
634+
schemaWith(CAMPAIGN_FIELDS),
635+
{ budget_code: 'BC-OLD-7731', api_token: 'tok-before-5512' },
636+
{ budget_code: 'BC-NEW-9924', api_token: 'tok-after-8843' },
637+
);
638+
expect(summary).toBe('Updated Campaign "CPG-0001": Budget Code and API Token');
639+
for (const value of ['BC-OLD-7731', 'BC-NEW-9924', 'tok-before-5512', 'tok-after-8843']) {
640+
expect(summary).not.toContain(value);
641+
}
642+
});
643+
644+
it('declares each named field as a summary source, so the read-side redaction drops the text for a reader not served one', async () => {
645+
const { metadata } = await updateCampaign(
646+
schemaWith(CAMPAIGN_FIELDS),
647+
{ num_sent: 12, budget_code: 'A' },
648+
{ num_sent: 13, budget_code: 'B' },
649+
);
650+
expect(metadata.text_sources.summary).toEqual(['name', 'num_sent', 'budget_code']);
651+
expect(metadata.text_sources.record_label).toEqual(['name']);
652+
});
653+
654+
it('names at most five fields and counts the rest, and declares the counted ones too', async () => {
655+
const fields: Record<string, any> = { id: { type: 'text' }, name: { type: 'text', label: 'Name' } };
656+
const previous: Record<string, any> = {};
657+
const result: Record<string, any> = {};
658+
for (let i = 1; i <= 8; i += 1) {
659+
fields[`f${i}`] = { type: 'number', label: `Field ${i}` };
660+
previous[`f${i}`] = 0;
661+
result[`f${i}`] = 1;
662+
}
663+
const { summary, metadata } = await updateCampaign(schemaWith(fields), previous, result);
664+
expect(summary).toBe(
665+
'Updated Campaign "CPG-0001": Field 1, Field 2, Field 3, Field 4, Field 5, and 3 more',
666+
);
667+
expect(metadata.text_sources.summary).toEqual(['name', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8']);
668+
});
669+
670+
it('keeps a wide update of long labels inside the declared sys_activity.summary bound (500)', async () => {
671+
const fields: Record<string, any> = { id: { type: 'text' }, name: { type: 'text', label: 'Name' } };
672+
const previous: Record<string, any> = {};
673+
const result: Record<string, any> = {};
674+
for (let i = 1; i <= 40; i += 1) {
675+
fields[`f${i}`] = { type: 'number', label: `A Very Long Field Label Number ${i} `.repeat(4).trim() };
676+
previous[`f${i}`] = 0;
677+
result[`f${i}`] = 1;
678+
}
679+
const { summary } = await updateCampaign(
680+
schemaWith(fields, { type: 'text', maxLength: 500 }),
681+
previous,
682+
result,
683+
);
684+
expect(summary.length).toBeLessThanOrEqual(500);
685+
// Fewer than five names fit, and the rest is still counted, not dropped.
686+
expect(summary).toMatch(/^Updated Campaign "CPG-0001": A Very Long Field Label Number 1 /);
687+
expect(summary).toMatch(/, and 3[6-9] more$/);
688+
});
689+
690+
it('falls back to the field-less frame when not even one name fits the bound', async () => {
691+
const { summary, metadata } = await updateCampaign(
692+
schemaWith({ ...CAMPAIGN_FIELDS, num_sent: { type: 'number', label: 'N'.repeat(60) } }, {
693+
type: 'text',
694+
maxLength: 40,
695+
}),
696+
{ num_sent: 1 },
697+
{ num_sent: 2 },
698+
);
699+
expect(summary).toBe('Updated Campaign "CPG-0001"');
700+
expect(metadata.text_sources.summary).toEqual(['name']);
579701
});
580702
});
581703

@@ -1047,7 +1169,42 @@ describe('audit writers — localized activity summaries (framework#3039)', () =
10471169
result: { id: 'q-1', name: 'OC-00001', status: 'active' },
10481170
});
10491171
const activity = created.find((c) => c.object === 'sys_activity');
1050-
expect(activity!.row.summary).toBe('更新了 人员资质 "OC-00001"');
1172+
// [#25787] `status` is undeclared here, so it is named by its key; the
1173+
// list and its frame are both the zh-CN bundle's.
1174+
expect(activity!.row.summary).toBe('更新了 人员资质 "OC-00001":status');
1175+
});
1176+
1177+
it('keeps a locale\'s field-less frame when its bundle has no named-field frame', async () => {
1178+
// An app-shipped locale that predates `activityUpdatedFields` gets its own
1179+
// localized sentence, never an English one with its field names.
1180+
const i18n = createMemoryI18n();
1181+
i18n.loadTranslations('fr-FR', {
1182+
objects: { person_qualification: { label: 'Qualification' } },
1183+
messages: { activityUpdated: 'A mis à jour {{object}} « {{label}} »' },
1184+
});
1185+
const { fire, created } = setup('fr-FR', i18n);
1186+
await fire('afterUpdate', {
1187+
...insertCtx(),
1188+
previous: { id: 'q-1', name: 'OC-00001', status: 'draft' },
1189+
result: { id: 'q-1', name: 'OC-00001', status: 'active' },
1190+
});
1191+
const activity = created.find((c) => c.object === 'sys_activity');
1192+
expect(activity!.row.summary).toBe('A mis à jour Qualification « OC-00001 »');
1193+
});
1194+
1195+
it('localizes the named-field list and its "more" item (zh-CN)', async () => {
1196+
const fields: Record<string, any> = { id: { type: 'text' }, name: { type: 'text' } };
1197+
const previous: Record<string, any> = { id: 'q-1', name: 'OC-00001' };
1198+
const result: Record<string, any> = { id: 'q-1', name: 'OC-00001' };
1199+
for (let i = 1; i <= 7; i += 1) {
1200+
fields[`f${i}`] = { type: 'number', label: `F${i}` };
1201+
previous[`f${i}`] = 0;
1202+
result[`f${i}`] = 1;
1203+
}
1204+
const { fire, created } = setup('zh-CN', makeI18n(), {}, { ...SINGLE_TENANT, person_qualification: fields });
1205+
await fire('afterUpdate', { ...insertCtx(), previous, result });
1206+
const activity = created.find((c) => c.object === 'sys_activity');
1207+
expect(activity!.row.summary).toBe('更新了 人员资质 "OC-00001":F1、F2、F3、F4、F5和另外 2 个字段');
10511208
});
10521209

10531210
it('falls back to the object def label, then English, when a translation misses', async () => {

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

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,8 @@ function planTrackedLookupReads(
760760
* summary ("Stage: Proposal → Closed Won"; multiple changes joined by "; "),
761761
* using the field label, select option labels and referenced record titles.
762762
* Returns null when no tracked field changed, so the caller falls back to the
763-
* generic "Updated <object>".
763+
* generic "Updated <object>", which names the changed fields
764+
* ({@link renderChangedFieldsSummary}).
764765
*
765766
* [#7230] `translate` is the SAME locale-bound translator the three sibling
766767
* summary branches already resolve through (`messages.activityCreated` /
@@ -798,9 +799,7 @@ function renderTrackedChangeSummary(
798799
for (const key of Object.keys(newVals)) {
799800
const field = fields[key];
800801
if (!field || field.trackHistory !== true) continue;
801-
const label =
802-
translate(`objects.${objectName}.fields.${key}.label`) ??
803-
(typeof field.label === 'string' && field.label.length > 0 ? field.label : key);
802+
const label = fieldLabelForSummary(objectName, key, field, translate);
804803
const reference = referenceTargetForSummary(field);
805804
const titlesFor = reference ? lookupTitles?.get(reference) : undefined;
806805
// Only a field that DECLARES options can consume this, so the ordinary
@@ -822,6 +821,116 @@ function renderTrackedChangeSummary(
822821
return parts.length > 0 ? { text: parts.join('; '), fields: rendered } : null;
823822
}
824823

824+
/**
825+
* A field's label inside an activity summary: the locale bundle's
826+
* (`objects.<object>.fields.<field>.label`), then the authored def label, then
827+
* the machine key. One answer for both branches that name a field.
828+
*/
829+
function fieldLabelForSummary(
830+
objectName: string,
831+
key: string,
832+
field: any,
833+
translate: (key: string, params?: Record<string, unknown>) => string | undefined,
834+
): string {
835+
return (
836+
translate(`objects.${objectName}.fields.${key}.label`) ??
837+
(typeof field?.label === 'string' && field.label.length > 0 ? field.label : key)
838+
);
839+
}
840+
841+
/**
842+
* [#25787] How many changed fields the generic update summary names before it
843+
* counts the rest ("… and 3 more").
844+
*/
845+
const MAX_NAMED_CHANGED_FIELDS = 5;
846+
847+
/**
848+
* [#25787] The generic update summary, naming WHICH fields changed — never
849+
* their values. Reached when no milestone fired and no `trackHistory` field
850+
* changed: before this, the row said only `Updated <object> "<label>"`, so a
851+
* record a rollup or flow bumps repeatedly showed a wall of identical entries
852+
* nobody could read. `trackHistory` still governs what it always did — whether
853+
* a field's old → new VALUES are rendered (ADR-0052 §5b.1); an untracked
854+
* field's value never reaches the text, only its label does.
855+
*
856+
* Names fields in declaration order (keys the definition does not carry come
857+
* after, by key), at most {@link MAX_NAMED_CHANGED_FIELDS}, then counts the
858+
* rest. When `sys_activity.summary` declares `maxLength` (the SQL driver sizes
859+
* the column `varchar(n)` from it, so an over-long text fails the whole
860+
* activity insert), fewer fields are named until the text fits. Returns null
861+
* when nothing changed, when not even one name fits, or when the locale has a
862+
* field-less frame but no named-field one, and the caller writes the
863+
* field-less frame.
864+
*
865+
* Returns the fields it COUNTED as well as named in `fields`: the read side
866+
* drops a summary composed from a field the reader is not served
867+
* (`activity-field-redaction.ts`), and "and 3 more" says three fields changed.
868+
*/
869+
function renderChangedFieldsSummary(
870+
objectName: string,
871+
fields: Record<string, any> | undefined | null,
872+
newVals: Record<string, any> | null,
873+
translate: (key: string, params?: Record<string, unknown>) => string | undefined,
874+
locale: string | undefined,
875+
frame: { object: string; label: string },
876+
maxLength: number | undefined,
877+
): { text: string; fields: string[] } | null {
878+
if (!newVals) return null;
879+
const changed = new Set(Object.keys(newVals));
880+
if (changed.size === 0) return null;
881+
const ordered = [
882+
...Object.keys(fields ?? {}).filter((key) => changed.has(key)),
883+
...[...changed].filter((key) => !fields || !(key in fields)).sort(),
884+
];
885+
const labels = ordered.map((key) => fieldLabelForSummary(objectName, key, fields?.[key], translate));
886+
// The list joins in the locale whose frame is used, so an English fallback
887+
// frame never carries a zh-CN "、" list. A locale whose bundle answers only
888+
// the field-less `activityUpdated` (an app's own locale, written before this
889+
// key existed) keeps that localized frame rather than an English one.
890+
const localized = translate('messages.activityUpdatedFields', { ...frame, fields: '' }) !== undefined;
891+
if (!localized && translate('messages.activityUpdated', frame) !== undefined) return null;
892+
const joinList = listJoinerFor(localized ? locale : undefined);
893+
const compose = (shown: number): string => {
894+
const items = labels.slice(0, shown);
895+
const rest = labels.length - shown;
896+
if (rest > 0) {
897+
items.push(translate('messages.activityMoreFields', { count: rest }) ?? `${rest} more`);
898+
}
899+
const list = joinList(items);
900+
return (
901+
translate('messages.activityUpdatedFields', { ...frame, fields: list }) ??
902+
`Updated ${frame.object} "${frame.label}": ${list}`
903+
);
904+
};
905+
for (let shown = Math.min(MAX_NAMED_CHANGED_FIELDS, labels.length); shown >= 1; shown -= 1) {
906+
const text = compose(shown);
907+
if (maxLength === undefined || text.length <= maxLength) return { text, fields: ordered };
908+
}
909+
return null;
910+
}
911+
912+
/** A locale's conjunction list ("A, B, and C" / "A、B和C"); `", "` where `Intl` cannot answer. */
913+
function listJoinerFor(locale: string | undefined): (items: string[]) => string {
914+
// `Intl.ListFormat` is ES2021; this package compiles against the ES2020 lib,
915+
// so the constructor is probed rather than assumed (as `plugin-auth`'s
916+
// invitation role label does).
917+
const ListFormat = (Intl as unknown as { ListFormat?: IntlListFormatConstructor }).ListFormat;
918+
try {
919+
if (ListFormat) {
920+
const format = new ListFormat(locale ?? 'en', { type: 'conjunction' });
921+
return (items) => format.format(items);
922+
}
923+
} catch {
924+
// A tag `Intl` refuses (a tenant's own locale spelling) still gets a list.
925+
}
926+
return (items) => items.join(', ');
927+
}
928+
929+
type IntlListFormatConstructor = new (
930+
locale: string,
931+
options: { type: 'conjunction' },
932+
) => { format(list: readonly string[]): string };
933+
825934
/**
826935
* [#7290] The `{token}` pattern of an `activityMilestones` summary template.
827936
*
@@ -1439,6 +1548,13 @@ export function installAuditWriters(
14391548
return fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : null;
14401549
};
14411550

1551+
// [#25787] The bound the registered `sys_activity.summary` declares, read
1552+
// from the definition like every other gate here; `undefined` = unbounded.
1553+
const activitySummaryMaxLength = (): number | undefined => {
1554+
const maxLength = getFieldDefs('sys_activity')?.summary?.maxLength;
1555+
return typeof maxLength === 'number' && maxLength > 0 ? maxLength : undefined;
1556+
};
1557+
14421558
/**
14431559
* [#7230, #21878] The field whose value titles a record of `objectName` in
14441560
* an activity row — ADR-0079's `resolveDisplayField` over the registered
@@ -2125,7 +2241,8 @@ export function installAuditWriters(
21252241
} else {
21262242
// ADR-0052 §5b — declarative activity, precedence: a configured semantic
21272243
// milestone (§5b.2) wins; else a tracked field-change diff ("Stage:
2128-
// Proposal → Closed Won", §5b.1); else the generic fallback.
2244+
// Proposal → Closed Won", §5b.1); else the generic fallback, naming the
2245+
// changed fields but not their values (#25787).
21292246
// [#6656] Masked views, not the raw pair. The milestone branch
21302247
// interpolates `{field}` from the after-row straight into a summary the
21312248
// record feed and Setup dashboards render verbatim, so it must never see
@@ -2183,9 +2300,24 @@ export function installAuditWriters(
21832300
summary = tracked.text;
21842301
summarySources = tracked.fields;
21852302
} else {
2186-
summary =
2187-
translate('messages.activityUpdated', { object: objectDisplay, label }) ??
2188-
`Updated ${objectDisplay} "${label}"`;
2303+
// [#25787] No tracked field changed: name the fields that did.
2304+
const named = renderChangedFieldsSummary(
2305+
ctx.object,
2306+
summaryFields,
2307+
newValue,
2308+
translate,
2309+
locale,
2310+
{ object: objectDisplay, label },
2311+
activitySummaryMaxLength(),
2312+
);
2313+
if (named) {
2314+
summary = named.text;
2315+
summarySources = [...new Set([...labelSources, ...named.fields])];
2316+
} else {
2317+
summary =
2318+
translate('messages.activityUpdated', { object: objectDisplay, label }) ??
2319+
`Updated ${objectDisplay} "${label}"`;
2320+
}
21892321
}
21902322
}
21912323
}

0 commit comments

Comments
 (0)