Skip to content

Commit c51aed4

Browse files
fix(plugin-approvals): an approval notification names the record by its title, as the recipient may read it (#26389)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XMBYHj1ZHBx7grUypasmFts)_ Fixes #26181 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:审批通知(铃铛和邮件正文)不再用记录 id 指代记录,改用记录标题,而且按每个收件人各自能看到的字段来定:收件人能读标题字段就显示标题,读不到、被掩码或标题为空就退回 id。一条通知发给多人且各人看到的不一样时,按名称拆成几条发,谁也拿不到别人才能看的标题。 - **为什么改**:「您的合同 C8Mp7TjRYta8mOWx 已获批准」收件人看不出是哪份合同;维护者已按 A′ 重新裁决(#26181 裁决 6429362190)。 - **风险与回滚**:风险在字段级安全的收窄上,所有失败路径都退回 id,不会泄露标题;每个收件人多一次权限解析(只在装了安全插件且记录有标题时)。回滚就是回退这个 PR。邮件里一键审批的令牌页面不变,仍显示 id。 - **先看**:`approval-service.ts` 的 `notify()` 和 `subjectNamesFor()`;新测试 `notification-subject-title-per-recipient.integration.test.ts`;`action-link-pages.ts` 新加的说明。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/notification-subject-title-per-recipient.integration.test.ts`,预期 4 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - **`notify()` names the record per recipient.** A notice about a record now hands `notify()` the record (`about`) and a builder for its sentence (`text`), instead of a text built once. `notify()` asks `subjectNamesFor()` for the name each recipient reads, groups the recipients by name, and emits once per name, so a recipient never receives a text built around another recipient's title. A dedup key on a split notice is suffixed with each part's first recipient so messaging does not fold one part into another (no current call site sends a multi-recipient notice with a dedup key that reaches messaging; the reminder to slot literals is dropped before emit). - **`subjectNamesFor()`** is the inbox's own title rule (`resolveSubjectDisplays`) asked as the recipient: the record is read once under the system context, and the title (ADR-0079 `nameField` via `resolveDisplayField`) is picked from it as served to the recipient, `titleAsServed()` over their `servedFields()` answer. The id stands when the record has no title, when the recipient may not read the title field or is served it masked, and when any read fails (at `warn`). `titleAsServed()` is extracted from `titlesAsServed()` so the inbox and the notifications share one spelling. - **`recipientContext()`** builds the recipient's envelope the way the `@mention` recipient gate in `plugin-audit` does: `resolveUserAuthzGrants` in the request's organization, the organization kept while `vetOrganizationClaim` backs it under the posture in force. An email address is looked up to its account; one no account carries gets the id. - **Cost.** One record read per notice; per recipient, one envelope resolution plus one field-visibility ask, and only when a field-visibility source is composed and the record has a title. With no security plugin the seam passes everything through, so no envelope is resolved. Most call sites notify one recipient; the slate-wide ones (SLA breach, escalation) pay per approver, the same resolution the administrators' slate already pays per member. Per-recipient is the rule itself (A′), so there is nothing to batch across recipients. - **Unchanged:** the message templates keep `{{object}} {{record}}` (no title-plus-id suffix); the literal-body notices (comment, request for information, send-back note) name no record; the token-bearing action-link pages keep the id, with a dated note beside the #23594 comment in `action-link-pages.ts`. - **Docs:** `content/docs/automation/approvals.mdx` said the outcome notification names the record as `Deal D_123`; it now says title as the recipient may read it, id as the fallback, id on the action pages. ## Tests - New `notification-subject-title-per-recipient.integration.test.ts` (real `ApprovalService` on `ObjectQL` + `SqlDriver`, a field-visibility double keyed by user): (a) a reader of the title field reads it in the literal body and the keyed `record` param, on `approval.requested` and `approval.approved`; (b) an empty title gives the id; (c) a recipient who may not read the title field, or is served it masked, gets the id and no trace of the title; (d) two recipients with different field-level security on one request get different bodies, both on per-approver emits (the opening) and on one emit naming both (the SLA breach, split into two emits). - Red first on `main`: with `approval-service.ts` and `approval-notification-text.ts` checked out at the base `6390fe52`, (a) and (d) fail (`Received: "A decision on Contract C1 is waiting on you."`); (b) and (c) pass there, as the fallback controls they are. - Ablation: `titleAsServed(record, displayField, readable)` mutated to ignore `readable` (via `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the HEAD blob) turns (c) and (d) red. - Updated, because they pinned ruling B's id in bodies whose fixtures carry a title: `approval-requested-notification` (the titled-record case now pins the live title, never the snapshot's), `approval-decision-outcome-notification`, `approval-notification-body` (the record name per call site), `approval-notification-keyed-text` (localized bodies and the end-to-end messaging render). ## Acceptance notes - Two copies of "the envelope a named user reads with" now exist: `plugin-audit`'s `contextForMentionedUser` and approvals' `recipientContext`. Lifting one into `@objectstack/core` is outside this card's file surface. Noted, not filed. - A cross-organization approver (ADR-0105 D9) with no membership in the request's organization never reaches a slate (`filterApproversWhoCanRead`), so the organization claim `recipientContext` vets holds for every approver that can be notified; one that does not hold resolves with no active organization and fails closed to the id. ## Verification All at head `6cc01b7264`, base `6390fe52`. <details><summary>Package suite and typecheck (under the verify lock)</summary> ``` pnpm --filter @objectstack/plugin-approvals test && pnpm --filter @objectstack/plugin-approvals typecheck Test Files 100 passed (100) Tests 1284 passed (1284) check:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles under packages/plugins/plugin-approvals/tsconfig.test.json os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Red on main, green on the fix</summary> ``` # approval-service.ts + approval-notification-text.ts at 6390fe5, the new test file at HEAD × (a) a recipient who may read the title reads it, literal and keyed alike × (d) two recipients with different field-level security on one request read different bodies Expected: "A decision on Contract Acme MSA 2026 is waiting on you." Received: "A decision on Contract C1 is waiting on you." Tests 2 failed | 2 passed (4) # restored: git diff HEAD empty, approval-service.ts blob fbf728e == HEAD # at HEAD Tests 4 passed (4) ``` </details> <details><summary>Ablation: the field-level security narrowing</summary> ``` ablation-replace: anchor "const title = ApprovalService.titleAsServed(record, displayField, readable);" x1 -> x0 ablation-replace: ok mutation landed: anchor 1 -> 0, blob fbf728e -> 05af8607f28b × (c) a recipient who may not read the title field, or is served it masked, is named the id × (d) two recipients with different field-level security on one request read different bodies Tests 2 failed | 2 passed (4) ablation-replace: ok restored: blob == HEAD (fbf728e) and `git diff HEAD` is empty ``` </details> <details><summary>Derived gates and lint</summary> ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack # 98 commands, each run with its exit captured before any pipe ✓ dispatch-gates --ran: 98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3). ``` Four exited 3 (prerequisite not met) on the first pass and were re-run green after the prerequisite: `check-plugin-teardown-shape --self-test` after fetching its pinned positive control `621a4876`; `check:i18n` after the build closure it names (`check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys)`); `check:skill-examples` (264 prose examples type-check); `check:dual-build-cjs-loads`. `pnpm exec eslint --no-inline-config packages/plugins/plugin-approvals/src` exit 0. The repo-wide `pnpm lint` and `test:repo` are CI's. </details> <details><summary>Overlap with open PRs</summary> Open PRs listed and each one's files read: only #26337 touches the same files (`approval-service.ts`, `approvals.mdx`). `git merge-tree --write-tree HEAD e451337` exits 0 (no conflict). #26180 has no open PR yet. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Kfr6fhXWwYppMd27Z6fPM8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48a9d9d commit c51aed4

11 files changed

Lines changed: 594 additions & 176 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): an approval notification names the record by its title, as the recipient may read it
6+
7+
Every approval notification body named its record by the object's label and the record id (`Your Contract C8Mp7TjRYta8mOWx was approved.`), so a recipient could not tell which record it was without opening it. Each notification is delivered to one named recipient, so it now names the record by its display title (ADR-0079 `nameField`) as that recipient may read it: the inbox's own rule (the record read under the system context, the title picked from it as served to the reader, narrowed by field-level security), asked as the recipient, whose envelope is resolved through `resolveUserAuthzGrants` in the request's organization. It falls back to the record id when the title is empty, when the recipient may not read the title field or is served it masked, and when anything on the way fails. A notification that names one record to several recipients goes out once per name they read, so no recipient is handed another recipient's title. The message templates keep their `{{object}} {{record}}` shape. The token-bearing one-tap action pages keep naming the record by its id: they have no reader to narrow for.
8+
9+
Clause-②: no
10+
Metadata-protocol: none

‎content/docs/automation/approvals.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -565,9 +565,13 @@ or that report the decision to anyone but the submitter.
565565
**The decision tells the submitter.** When a decision ends the approval, the
566566
submitter of the request it decides gets one notification: `approval.approved`
567567
("Request approved") or `approval.rejected` ("Request rejected"). Its body
568-
names the record (`Deal D_123`) and the outcome, followed by the decider's
569-
comment when they wrote one, and it links to that request in the Approvals
570-
Inbox. There is one topic per outcome, so a delivery preference can mute
568+
names the record and the outcome, followed by the decider's comment when they
569+
wrote one, and it links to that request in the Approvals Inbox. Like every
570+
approval notification, it names the record by its object's label and its
571+
title as that recipient may read it (`Deal Acme renewal`), and by its id
572+
(`Deal D_123`) when the title is empty or the recipient's field-level security
573+
hides the title field. The one-tap email action pages always name the record
574+
by its id, because they are opened with a token rather than a session. There is one topic per outcome, so a delivery preference can mute
571575
approvals and keep rejections.
572576

573577
"Ends the approval" means the process, not the step. A co-sign vote that

‎packages/plugins/plugin-approvals/src/action-link-pages.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ function shell(title: string, body: string): string {
5757
* (#10749). These pages have no reader to narrow for: the bearer carries a
5858
* token, not a session, and the token chain reads the request under the system
5959
* context, which field-level security does not narrow. So no subject field is
60-
* shown here — the record is named the way the approval notification names it,
61-
* by the object's label and the record's id.
60+
* shown here — the record is named by the object's label and the record's id.
61+
*
62+
* [#26181, 2026-10-27] The notification surfaces were re-ruled (ruling A′):
63+
* an approval notification is rendered for one named recipient, so it names
64+
* the record by its title as that recipient may read it, else its id
65+
* (`ApprovalService.subjectNamesFor`). These pages keep the id: the reason
66+
* above, no reader to narrow for, still holds here.
6267
*/
6368
function summaryRows(req: ApprovalRequestRow): string {
6469
const rows: Array<[string, string]> = [

‎packages/plugins/plugin-approvals/src/approval-decision-outcome-notification.integration.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe('the decision that ends an approval tells its submitter (approval.appro
234234
expect(told.map((e) => [e.topic, e.audience])).toEqual([['approval.approved', ['u_sub']]]);
235235
expect(told[0].payload).toEqual({
236236
title: 'Request approved',
237-
body: 'Your Deal D_won was approved.',
237+
body: 'Your Deal won was approved.',
238238
actionUrl: `/system/approvals?request=${encodeURIComponent(req.id)}`,
239239
});
240240
expect(told[0].source).toEqual({ object: 'sys_approval_request', id: req.id });
@@ -251,7 +251,7 @@ describe('the decision that ends an approval tells its submitter (approval.appro
251251
expect(told.map((e) => [e.topic, e.audience])).toEqual([['approval.rejected', ['u_sub']]]);
252252
expect(told[0].payload).toEqual({
253253
title: 'Request rejected',
254-
body: `Your Deal D_lost was rejected.\n\n${REJECT_NOTE}`,
254+
body: `Your Deal lost was rejected.\n\n${REJECT_NOTE}`,
255255
actionUrl: `/system/approvals?request=${encodeURIComponent(req.id)}`,
256256
});
257257
expect(told[0].actorId).toBe('u_alice');

‎packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts‎

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,17 @@
3939
*
4040
* ## And how it names the record (#23978)
4141
*
42-
* Ruling B of #23556 / #23594 names the record an approval notification is
43-
* about by its object's label and its id (`Deal D_123`), with no title, through
44-
* `nameSubjectRecord`. `approval.requested` used it; every other body that
42+
* Every body names the record an approval notification is about by its
43+
* object's label, the way `approval.requested` did; every other body that
4544
* names the record built `<object_name>/<record_id>`, so an approver read the
4645
* API name and a slash in the reminder, the SLA breach, the escalation, the
4746
* hand-off, both send-back sentences and both out-of-office sentences. The same
4847
* battery pins the record's name in each of those bodies, and a control: a
4948
* record of an object whose schema has no label is named by the object's API
50-
* name and the id, the helper's own fallback.
49+
* name, the helper's own fallback. [#26181 ruling A′] The record itself is
50+
* named by its title as the recipient is served it (here every recipient: no
51+
* field-visibility source is composed), once ruling B's id
52+
* (`notification-subject-title-per-recipient` pins the narrowing).
5153
*
5254
* ## And whom the out-of-office bodies name (#24065)
5355
*
@@ -343,29 +345,30 @@ describe('every approval notification carries its text in body', () => {
343345
expect([sentBack.audience, sentBack.payload?.body]).toEqual([['u_sub'], SEND_BACK]);
344346
// [#24753] The rejection note follows the outcome sentence, which names the record.
345347
const rejected = one('approval.rejected', 'rejected');
346-
expect([rejected.audience, rejected.payload?.body]).toEqual([['u_sub'], `Your Deal D_rejected was rejected.\n\n${REJECTION}`]);
348+
expect([rejected.audience, rejected.payload?.body]).toEqual([['u_sub'], `Your Deal rejected was rejected.\n\n${REJECTION}`]);
347349
});
348350

349-
it('[#23978] every body that names the record names it by its object label and its id, as approval.requested does', () => {
350-
// Ruling B of #23556 / #23594: label and id, no title — one form for every
351-
// body, the one `approval.requested` already sends. The pin is the record's
352-
// name in each body, not the rest of the sentence around it.
351+
it('[#23978 / #26181] every body that names the record names it by its object label and its title, as approval.requested does', () => {
352+
// One form for every body, the one `approval.requested` sends: the
353+
// object's label and the record's title as its recipient is served it
354+
// (ruling A′, which re-ruled #23556 / #23594 B's id). The pin is the
355+
// record's name in each body, not the rest of the sentence around it.
353356
const NAMED: Record<string, string> = {
354-
'approval.requested@thread': 'Deal D_thread',
355-
'approval.reminder@thread': 'Deal D_thread',
356-
'approval.sla_breached@sla_notify': 'Deal D_sla_notify',
357-
'approval.sla_breached@sla_notify>submitter': 'Deal D_sla_notify',
358-
'approval.escalated@sla_reassign': 'Deal D_sla_reassign',
359-
'approval.reassigned@thread': 'Deal D_thread',
360-
'approval.returned@budget': 'Deal D_budget',
361-
'approval.returned@rework': 'Deal D_rework',
362-
'approval.ooo_substituted@ooo': 'Deal D_ooo',
363-
'approval.ooo_skipped@ooo': 'Deal D_ooo',
364-
'approval.approved@approved': 'Deal D_approved',
365-
'approval.rejected@rejected': 'Deal D_rejected',
357+
'approval.requested@thread': 'Deal thread',
358+
'approval.reminder@thread': 'Deal thread',
359+
'approval.sla_breached@sla_notify': 'Deal sla_notify',
360+
'approval.sla_breached@sla_notify>submitter': 'Deal sla_notify',
361+
'approval.escalated@sla_reassign': 'Deal sla_reassign',
362+
'approval.reassigned@thread': 'Deal thread',
363+
'approval.returned@budget': 'Deal budget',
364+
'approval.returned@rework': 'Deal rework',
365+
'approval.ooo_substituted@ooo': 'Deal ooo',
366+
'approval.ooo_skipped@ooo': 'Deal ooo',
367+
'approval.approved@approved': 'Deal approved',
368+
'approval.rejected@rejected': 'Deal rejected',
366369
// The control: the object's schema has no label, so the record is named
367-
// by the object's API name and the id.
368-
'approval.reminder@bare': 'crm_memo D_bare',
370+
// by the object's API name and its title.
371+
'approval.reminder@bare': 'crm_memo bare',
369372
};
370373
const offenders = Object.entries(NAMED).flatMap(([site, name]) => {
371374
const bodies = emits.filter((e) => siteOf(e) === site).map((e) => String(e.payload?.body));

‎packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,10 @@ describe('#24978 — every approval notification carries its text as catalog key
277277
if (out.body === SENTINEL.body || out.body === e.payload?.body || out.body.includes('{{')) {
278278
offenders.push(`${site} ${locale} body: ${out.body}`);
279279
}
280-
// Every body sentence names the record; in this locale, by this locale's label.
281-
if (!out.body.includes(`${DEAL_LABELS[locale]} D_`)) offenders.push(`${site} ${locale} body names no localized record: ${out.body}`);
280+
// Every body sentence names the record; in this locale, by this locale's
281+
// label, and [#26181] by its title (each deal is titled by its key).
282+
const title = site.split('@')[1]?.replace('>submitter', '');
283+
if (!out.body.includes(`${DEAL_LABELS[locale]} ${title}`)) offenders.push(`${site} ${locale} body names no localized record: ${out.body}`);
282284
}
283285
}
284286
expect(offenders).toEqual([]);
@@ -344,18 +346,18 @@ describe('#24978 — end to end: a real messaging service renders each approver\
344346
}, asUser('u_sub'));
345347

346348
expect(await inboxOf('u_zh')).toEqual([
347-
{ topic: 'approval.requested', title: '新的审批请求', body: '商机 D_1 正在等待您的审批决定。' },
349+
{ topic: 'approval.requested', title: '新的审批请求', body: '商机 Big deal 正在等待您的审批决定。' },
348350
]);
349351
expect(await inboxOf('u_en')).toEqual([
350-
{ topic: 'approval.requested', title: 'Approval requested', body: 'A decision on Deal D_1 is waiting on you.' },
352+
{ topic: 'approval.requested', title: 'Approval requested', body: 'A decision on Deal Big deal is waiting on you.' },
351353
]);
352354
expect(await inboxOf('u_none')).toEqual([
353-
{ topic: 'approval.requested', title: '承認依頼', body: '商談 D_1 について、あなたの承認判断が必要です。' },
355+
{ topic: 'approval.requested', title: '承認依頼', body: '商談 Big deal について、あなたの承認判断が必要です。' },
354356
]);
355357

356358
await svc.decide(request.id, { decision: 'approve', actorId: 'u_en', comment: 'Looks good — ship it.' }, asUser('u_en'));
357359
expect(await inboxOf('u_sub')).toEqual([
358-
{ topic: 'approval.approved', title: '请求已批准', body: '您的商机 D_1 已获批准。\n\nLooks good — ship it.' },
360+
{ topic: 'approval.approved', title: '请求已批准', body: '您的商机 Big deal 已获批准。\n\nLooks good — ship it.' },
359361
]);
360362
});
361363
});

‎packages/plugins/plugin-approvals/src/approval-notification-text.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@
2727
* ## The record a notification names
2828
*
2929
* `object` and `record` ({@link subjectParams}): the object's label and the
30-
* record id, the #23556 / #23594 ruling B form, never a title. The label rides
31-
* as a keyed param — `objects.<object>.label`, the key the reader-language
30+
* record's name. [#26181 ruling A′] The name is the record's display title as
31+
* the one recipient the text is built for may read it, else the record id
32+
* (`ApprovalService.notify` resolves it per recipient); the #23556 / #23594
33+
* ruling B form, label and id for every reader, stands only on the
34+
* reader-less action-link pages. The label rides as a keyed param — `objects.<object>.label`, the key the reader-language
3235
* labels (`reader-locale-labels.ts`) and objectql's refusals name an object
3336
* by — so a recipient whose language pack holds it reads it in their language;
3437
* the schema label (the API name when there is none) stands otherwise.
@@ -75,18 +78,19 @@ export function approvalMessageKey(id: ApprovalMessageId): string {
7578
}
7679

7780
/**
78-
* The record `objectName`/`recordId` names: the object's label — keyed by
79-
* `objects.<object>.label`, falling back to `objectLabel` (the schema label),
80-
* else the API name — and the record id.
81+
* The record of `objectName` a notification names: the object's label — keyed
82+
* by `objects.<object>.label`, falling back to `objectLabel` (the schema
83+
* label), else the API name — and `record`, the name its recipient reads for
84+
* it (its title as they may read it, else its id).
8185
*/
8286
export function subjectParams(
8387
objectName: string,
8488
objectLabel: string | undefined,
85-
recordId: string,
89+
record: string,
8690
): ApprovalSubjectParams {
8791
return {
8892
object: { key: objectLabelKey(objectName), fallback: objectLabel ?? objectName },
89-
record: recordId,
93+
record,
9094
};
9195
}
9296

‎packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
* approver on the slate it opened on — a named user and every holder of a
2222
* staffed position alike — through the same `notify()` ingress and payload
2323
* shape as `approval.reminder`;
24-
* - [#23556] the body names the record by its object's label and its id, and
25-
* [#23594] carries no title, even for a record whose title resolves — not
26-
* the live record's and not the submit snapshot's;
24+
* - [#23556] the body names the record by its object's label, and [#26181]
25+
* by the title the inbox serves its recipient — the live record's, never
26+
* the submit snapshot's (with no field-visibility source composed, every
27+
* recipient is served it; `notification-subject-title-per-recipient` pins
28+
* the narrowing);
2729
* - a person the slate lists twice is one person, and a `type:value` slot
2830
* literal (an unstaffed position) names no one;
2931
* - the control: a step whose approvers resolve to nobody notifies no one,
@@ -192,13 +194,13 @@ describe('opening an approval step tells each resolved approver, once (approval.
192194

193195
// One message per person, each addressed to that person alone, in the
194196
// reminder's shape: the request as its source, the inbox deep-linked to
195-
// it, the submitter as the actor. [#23556] The body names the record by
196-
// its object's label and its id, never `crm_deal/D_ladder`.
197+
// it, the submitter as the actor. [#23556 / #26181] The body names the
198+
// record by its object's label and its title, never `crm_deal/D_ladder`.
197199
for (const e of requested(row.id)) {
198200
expect(e.audience).toHaveLength(1);
199201
expect(e.payload).toEqual({
200202
title: 'Approval requested',
201-
body: `A decision on Deal D_ladder is waiting on you.`,
203+
body: `A decision on Deal ladder is waiting on you.`,
202204
actionUrl: `/system/approvals?request=${encodeURIComponent(row.id)}`,
203205
});
204206
expect(e.source).toEqual({ object: 'sys_approval_request', id: row.id });
@@ -207,12 +209,12 @@ describe('opening an approval step tells each resolved approver, once (approval.
207209
expect(new Set(requested(row.id).map((e) => e.dedupKey)).size).toBe(3);
208210
});
209211

210-
it('[#23594] a record whose title resolves is still named by label and id — no title reaches the body', async () => {
212+
it('[#26181] a record whose title resolves is named by the title the inbox serves its recipient, never the snapshot\'s', async () => {
211213
// The inbox narrows the subject title by its reader's field-level security
212-
// (#23594), but a notification has no reader to narrow for, and its text is
213-
// stored and may be e-mailed, so the body withholds the title from EVERY
214-
// source: the live record's and the submit snapshot's (which differ here,
215-
// so either leak is told apart).
214+
// (#23594), and a notification is rendered for one recipient, so its body
215+
// names the record by the title that recipient is served: the live
216+
// record's, never the submit snapshot's (which differ here, so the source
217+
// is told apart).
216218
const LIVE_TITLE = 'Acme renewal';
217219
const SNAPSHOT_TITLE = 'Snapshot-only title';
218220
await engine.insert('crm_deal', { id: 'D_titled', title: LIVE_TITLE }, { context: SYSTEM } as any);
@@ -221,16 +223,15 @@ describe('opening an approval step tells each resolved approver, once (approval.
221223
config: { approvers: [{ type: 'user', value: 'u_alice' }], behavior: 'first_response' } as any,
222224
submitterId: 'u_sub', record: { id: 'D_titled', title: SNAPSHOT_TITLE },
223225
}, asUser('u_sub'));
224-
// The scene: both titles really are there to leak — the inbox resolves the
225-
// live one for this very recipient, and the snapshot carries its own.
226+
// The scene: both titles are there — the inbox resolves the live one for
227+
// this very recipient, and the snapshot carries its own.
226228
const [listed] = await svc.listRequests({ approverId: 'u_alice', recordId: 'D_titled' }, asUser('u_alice'));
227229
expect(listed?.record_title).toBe(LIVE_TITLE);
228230
expect(row.payload).toMatchObject({ title: SNAPSHOT_TITLE });
229231

230232
const [message] = requested(row.id);
231233
expect(message?.audience).toEqual(['u_alice']);
232-
expect(message?.payload?.body).toBe('A decision on Deal D_titled is waiting on you.');
233-
expect(String(message?.payload?.body)).not.toContain(LIVE_TITLE);
234+
expect(message?.payload?.body).toBe(`A decision on Deal ${LIVE_TITLE} is waiting on you.`);
234235
expect(String(message?.payload?.body)).not.toContain(SNAPSHOT_TITLE);
235236
});
236237

0 commit comments

Comments
 (0)