diff --git a/.changeset/21847-approval-notification-body.md b/.changeset/21847-approval-notification-body.md
new file mode 100644
index 00000000000..e21712a7704
--- /dev/null
+++ b/.changeset/21847-approval-notification-body.md
@@ -0,0 +1,12 @@
+---
+'@objectstack/plugin-approvals': patch
+---
+
+Approval notifications reach their recipient with their text (#21847). The approvals service put each notification's text in `payload.message`. The messaging service builds the delivered notification from `payload.title` and `payload.body`, the fields its `EmitInput` documents, and no channel reads `message`. So `GET /api/v1/notifications` served every approval notification as a title over an empty `body`, and the inbox row's `body_md` was empty too. The lost texts were comments, request-info questions, send-back notes, reassignments, reminders, escalations, SLA breaches and out-of-office substitutions. Every one of them now travels in `payload.body`.
+
+Clause-②: no
+
+- The texts are unchanged. Only the field they travel in moved. Who is notified, and when, is unchanged.
+- Approval notifications no longer carry `payload.message`. Nothing in the platform or the console read it. A tenant-authored `sys_notification_template` for an `approval.*` topic that wrote `{{ message }}` should write `{{ body }}`.
+- The service's notify helper now declares its payload: `title`, `body`, `actionUrl`, and a reminder's `actions`. A call site that spells the text any other way no longer compiles.
+- `@objectstack/service-messaging` is unchanged. There is one field, as documented, and no alias for the old one.
diff --git a/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts
new file mode 100644
index 00000000000..f9d00415529
--- /dev/null
+++ b/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts
@@ -0,0 +1,251 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Every approval notification carries its text in `body` — the one field the
+ * `messaging` service's `EmitInput` documents for it, and the only one the
+ * delivered notification is built from.
+ *
+ * ## The defect
+ *
+ * Every approval notification reached its recipient as a title over an empty
+ * body. The service put its text in `payload.message`; messaging reads
+ * `payload.title` and `payload.body` (the inline fan-out, the outbox snapshot
+ * and the dispatcher alike), so the comment, the request-info question and
+ * every escalation line were dropped on the way to the inbox. The flow
+ * `notify` node and the @mention producer already send `body`.
+ *
+ * ## What this pins
+ *
+ * The fix is at the producer, with no alias in messaging. `notify()` types its
+ * payload, so a call site that spells the text any other way does not compile;
+ * this file pins the behaviour across EVERY call site the service has, driven
+ * through the real `ApprovalService` on a real `ObjectQL` over `SqlDriver`
+ * (better-sqlite3, in-memory, real DDL):
+ *
+ * - a comment and a request-info question ARE the body their recipient gets;
+ * - every notification carries a non-empty `body` and no field outside the
+ * payload `notify()` declares — so a second spelling of the text is red;
+ * - the battery reaches every call site that can notify anyone — eleven of
+ * the twelve — so neither pin passes over a site it never drove.
+ *
+ * The twelfth, the reminder to a slot literal (`position:
`), never reaches
+ * messaging at all: `notify()` drops every audience entry that is a slot
+ * address, and that reminder's audience is nothing else. Its payload is held
+ * to the same declared shape by the compiler, not by this battery.
+ *
+ * The end-to-end half — the text read back from the recipient's
+ * `GET /api/v1/notifications` through the real messaging service — is the
+ * dogfood pin `packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { ObjectQL } from '@objectstack/objectql';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation';
+import { ApprovalService } from './approval-service.js';
+import { ApprovalsServicePlugin } from './approvals-plugin.js';
+
+const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any;
+const asUser = (userId: string, positions: string[] = []) =>
+ ({ userId, positions, permissions: [] }) as any;
+
+const USERS = [
+ { id: 'u_sub', name: 'Sam Submitter', email: 'sam@example.com' },
+ { id: 'u_legal', name: 'Lee Legal', email: 'lee@example.com' },
+ { id: 'u_alice', name: 'Alice', email: 'alice@example.com' },
+ { id: 'u_bob', name: 'Bob', email: 'bob@example.com' },
+ { id: 'u_ooo', name: 'Olive Away', email: 'olive@example.com' },
+ { id: 'u_boss', name: 'Boss', email: 'boss@example.com' },
+];
+
+/** The text each person writes — distinct, so no one can pass for another. */
+const COMMENT = 'The signed contract is attached to the record now.';
+const QUESTION = 'Which cost centre should this be booked against?';
+const SEND_BACK = 'Split the total by quarter, please.';
+
+/** The fields `notify()` declares for a notification's payload. */
+const PAYLOAD_FIELDS = new Set(['title', 'body', 'actionUrl', 'actions']);
+
+const sysUser = {
+ name: 'sys_user',
+ label: 'User',
+ fields: {
+ id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
+ name: { name: 'name', label: 'Name', type: 'text' as const },
+ email: { name: 'email', label: 'Email', type: 'text' as const },
+ },
+};
+const deal = {
+ name: 'crm_deal',
+ label: 'Deal',
+ fields: {
+ id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
+ title: { name: 'title', label: 'Title', type: 'text' as const },
+ },
+};
+
+/** The flow the requests belong to: an approval node with a revise window. */
+const FLOW = {
+ name: 'deal_review',
+ nodes: [
+ { id: 'review', type: 'approval' },
+ { id: 'rework', type: APPROVAL_REVISE_NODE_TYPE },
+ ],
+ edges: [{ id: 'e_revise', source: 'review', target: 'rework', label: 'revise' }],
+};
+
+/** The plugin's own object definitions, as its manifest registers them. */
+async function packageObjects(): Promise {
+ let manifest: any;
+ const ctx: any = {
+ getService: (name: string) => {
+ if (name === 'manifest') return { register: (m: any) => { manifest = m; } };
+ throw new Error(`no service '${name}'`);
+ },
+ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
+ };
+ await new ApprovalsServicePlugin().init(ctx);
+ return manifest.objects;
+}
+
+interface Emitted {
+ topic: string;
+ audience: string[];
+ source?: { object: string; id: string };
+ payload?: Record;
+}
+
+describe('every approval notification carries its text in body', () => {
+ let engine: ObjectQL;
+ let svc: ApprovalService;
+ let now = Date.parse('2026-10-01T09:00:00.000Z');
+ const emits: Emitted[] = [];
+ const ids: Record = {};
+
+ const open = async (key: string, approvers: any[], extra: Record = {}) => {
+ await engine.insert('crm_deal', { id: `D_${key}`, title: key }, { context: SYSTEM } as any);
+ const row: any = await svc.openNodeRequest({
+ object: 'crm_deal', recordId: `D_${key}`, runId: `run_${key}`, nodeId: 'review', flowName: FLOW.name,
+ config: { approvers, behavior: 'first_response', ...extra } as any,
+ submitterId: 'u_sub', record: { id: `D_${key}`, title: key },
+ }, asUser('u_sub'));
+ ids[key] = row.id;
+ return row;
+ };
+
+ /** Which call site an emit came from: its topic, its request, and what tells two sites on one request apart. */
+ const siteOf = (e: Emitted): string => {
+ const key = Object.keys(ids).find((k) => ids[k] === e.source?.id) ?? '?';
+ const links = Array.isArray(e.payload?.actions) ? '+links' : '';
+ const toSubmitter = e.topic === 'approval.sla_breached' && e.audience.join() === 'u_sub' ? '>submitter' : '';
+ return `${e.topic}@${key}${links}${toSubmitter}`;
+ };
+
+ beforeAll(async () => {
+ engine = new ObjectQL();
+ engine.registerDriver(new SqlDriver({
+ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
+ }), true);
+ await engine.init();
+ for (const def of [sysUser, deal, ...(await packageObjects())]) {
+ engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test');
+ }
+ await engine.syncSchemas();
+ for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any);
+
+ svc = new ApprovalService({
+ engine: engine as any,
+ // Every reading moves a second on, so the action log has one order.
+ clock: { now: () => new Date(now += 1000) },
+ automation: {
+ resume: async () => ({ status: 'completed' }),
+ cancelRun: async () => undefined,
+ getRun: async (runId: string) => ({ id: runId, status: 'paused' }),
+ getFlow: async () => FLOW,
+ } as any,
+ logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any,
+ });
+ svc.attachMessaging({ async emit(input) { emits.push(input as Emitted); } });
+
+ // ── The thread: a position slot (a literal no account holds) and a person ──
+ const thread = await open('thread', [{ type: 'position', value: 'legal' }, { type: 'user', value: 'u_alice' }],
+ { behavior: 'unanimous' });
+ expect(thread.pending_approvers).toEqual(['position:legal', 'u_alice']);
+ await svc.comment(ids.thread, { actorId: 'u_sub', comment: COMMENT }, asUser('u_sub'));
+ await svc.requestInfo(ids.thread, { actorId: 'u_alice', comment: QUESTION }, asUser('u_alice'));
+ // A remind reaches the person with their own action links.
+ await svc.remind(ids.thread, { actorId: 'u_sub' }, asUser('u_sub'));
+ await svc.reassign(ids.thread, { actorId: 'u_alice', to: 'u_bob' }, asUser('u_alice'));
+
+ // ── Send back, within budget and past it ──
+ await open('revise', [{ type: 'user', value: 'u_alice' }]);
+ await svc.sendBack(ids.revise, { actorId: 'u_alice', comment: SEND_BACK } as any, asUser('u_alice'));
+ await open('budget', [{ type: 'user', value: 'u_alice' }], { maxRevisions: 0 });
+ await svc.sendBack(ids.budget, { actorId: 'u_alice' } as any, asUser('u_alice'));
+
+ // ── Out of office: the routing substitutes a delegate ──
+ await engine.insert('sys_approval_delegation', {
+ id: 'dlg_1', delegator_id: 'u_ooo', delegate_id: 'u_bob', reason: 'leave',
+ valid_from: '2026-09-01T00:00:00.000Z', valid_until: '2026-12-31T00:00:00.000Z',
+ created_at: '2026-09-01T00:00:00.000Z', updated_at: '2026-09-01T00:00:00.000Z',
+ }, { context: SYSTEM } as any);
+ const ooo = await open('ooo', [{ type: 'user', value: 'u_ooo' }]);
+ expect(ooo.pending_approvers).toEqual(['u_bob']);
+
+ // ── The SLA sweep: a notify (submitter told) and a reassign (submitter not) ──
+ await open('sla_notify', [{ type: 'user', value: 'u_alice' }],
+ { escalation: { timeoutHours: 1, action: 'notify', escalateTo: 'u_boss', notifySubmitter: true } });
+ await open('sla_reassign', [{ type: 'user', value: 'u_alice' }],
+ { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'u_boss', notifySubmitter: false } });
+ now += 2 * 3600_000;
+ expect(await svc.runEscalations()).toMatchObject({ escalated: 2 });
+ });
+
+ afterAll(async () => {
+ try { await engine?.destroy(); } catch { /* noop */ }
+ });
+
+ it('the battery reaches every call site that can notify anyone — eleven of the twelve', () => {
+ expect(new Set(emits.map(siteOf))).toEqual(new Set([
+ 'approval.ooo_substituted@ooo',
+ 'approval.ooo_skipped@ooo',
+ 'approval.returned@budget',
+ 'approval.returned@revise',
+ 'approval.reassigned@thread',
+ 'approval.reminder@thread+links',
+ 'approval.request_info@thread',
+ 'approval.comment@thread',
+ 'approval.escalated@sla_reassign',
+ 'approval.sla_breached@sla_notify',
+ 'approval.sla_breached@sla_notify>submitter',
+ ]));
+ });
+
+ it("a comment, a request-info question and a send-back note are their recipient's body", () => {
+ const one = (topic: string, key: string) => {
+ const hits = emits.filter((e) => e.topic === topic && e.source?.id === ids[key]);
+ expect(hits.length, `${topic} on ${key}`).toBe(1);
+ return hits[0];
+ };
+ const comment = one('approval.comment', 'thread');
+ expect([comment.audience, comment.payload?.body]).toEqual([['u_alice'], COMMENT]);
+ const question = one('approval.request_info', 'thread');
+ expect([question.audience, question.payload?.body]).toEqual([['u_sub'], QUESTION]);
+ const sentBack = one('approval.returned', 'revise');
+ expect([sentBack.audience, sentBack.payload?.body]).toEqual([['u_sub'], SEND_BACK]);
+ });
+
+ it('every notification carries a non-empty body, and no field the payload does not declare', () => {
+ const offenders: string[] = [];
+ for (const e of emits) {
+ const payload = e.payload ?? {};
+ if (typeof payload.body !== 'string' || payload.body.trim() === '') {
+ offenders.push(`${siteOf(e)}: body is ${JSON.stringify(payload.body)}`);
+ }
+ for (const field of Object.keys(payload)) {
+ if (!PAYLOAD_FIELDS.has(field)) offenders.push(`${siteOf(e)}: undeclared payload field '${field}'`);
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts
index 6ea9ca90046..9a1822845a2 100644
--- a/packages/plugins/plugin-approvals/src/approval-service.ts
+++ b/packages/plugins/plugin-approvals/src/approval-service.ts
@@ -280,6 +280,28 @@ export interface ApprovalMessagingSurface {
}): Promise;
}
+/**
+ * What an approval notification hands to the messaging service: the
+ * `EmitInput` payload fields the delivered notification is built from, plus a
+ * reminder's one-tap decision links.
+ *
+ * `body` is the text the recipient reads under the title. Messaging builds the
+ * inbox row's `body_md`, and each channel's fallback rendering, from
+ * `payload.body` and no other field, so the text is declared here rather than
+ * left to a free-form bag: a call site that spells it any other way does not
+ * compile. Spelled `message`, it once reached every recipient as an empty body.
+ */
+type ApprovalNotificationPayload = {
+ /** The headline. */
+ readonly title: string;
+ /** The text under the headline. */
+ readonly body: string;
+ /** Where opening it lands; `notify()` narrows the approvals inbox to the request. */
+ readonly actionUrl: string;
+ /** Approve / Reject links on a reminder to a concrete approver (ADR-0043). */
+ readonly actions?: ReadonlyArray<{ readonly label: string; readonly url: string }>;
+};
+
/** Minimum time between submitter reminders on one request. */
export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000;
@@ -1373,7 +1395,7 @@ export class ApprovalService implements IApprovalService {
private async notify(input: {
topic: string;
audience: string[];
- payload?: Record;
+ payload: ApprovalNotificationPayload;
dedupKey?: string;
source?: { object: string; id: string };
actorContext?: ExecutionContext;
@@ -1388,7 +1410,7 @@ export class ApprovalService implements IApprovalService {
// the console inbox to auto-open the drawer.
let payload = event.payload;
if (
- payload?.actionUrl === '/system/approvals'
+ payload.actionUrl === '/system/approvals'
&& event.source?.object === 'sys_approval_request'
&& event.source.id
) {
@@ -3073,7 +3095,7 @@ export class ApprovalService implements IApprovalService {
dedupKey: `approval-ooo-${id}-${sub.to}`,
payload: {
title: 'Approval routed to you (out-of-office cover)',
- message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
+ body: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
actionUrl: '/system/approvals',
},
});
@@ -3084,7 +3106,7 @@ export class ApprovalService implements IApprovalService {
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
payload: {
title: 'Approval routed to your delegate',
- message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
+ body: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
actionUrl: '/system/approvals',
},
});
@@ -4206,7 +4228,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: requestId },
payload: {
title: 'Approval auto-rejected',
- message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
+ body: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
actionUrl: '/system/approvals',
},
});
@@ -4249,7 +4271,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: requestId },
payload: {
title: 'Sent back for revision',
- message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
+ body: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
actionUrl: '/system/approvals',
},
});
@@ -4509,7 +4531,7 @@ export class ApprovalService implements IApprovalService {
dedupKey: `approval-reassign-${requestId}-${to}`,
payload: {
title: 'Approval handed to you',
- message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
+ body: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
actionUrl: '/system/approvals',
},
});
@@ -4568,7 +4590,7 @@ export class ApprovalService implements IApprovalService {
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
payload: {
title: 'Approval reminder',
- message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
+ body: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
actionUrl: '/system/approvals',
actions: [
{ label: 'Approve', url: this.actionLinkUrl(tokens.approve) },
@@ -4591,7 +4613,7 @@ export class ApprovalService implements IApprovalService {
dedupKey: `approval-remind-${requestId}-${nowIso}`,
payload: {
title: 'Approval reminder',
- message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
+ body: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
actionUrl: '/system/approvals',
},
});
@@ -4785,7 +4807,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: requestId },
payload: {
title: 'More information requested',
- message: input.comment.trim(),
+ body: input.comment.trim(),
actionUrl: '/system/approvals',
},
});
@@ -4832,7 +4854,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: requestId },
payload: {
title: 'New comment on an approval',
- message: input.comment.trim(),
+ body: input.comment.trim(),
actionUrl: '/system/approvals',
},
});
@@ -5877,7 +5899,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: raw.id },
payload: {
title: 'Approval escalated to you',
- message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
+ body: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
actionUrl: '/system/approvals',
},
});
@@ -5895,7 +5917,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: raw.id },
payload: {
title: 'Approval SLA breached',
- message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
+ body: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
actionUrl: '/system/approvals',
},
});
@@ -5908,7 +5930,7 @@ export class ApprovalService implements IApprovalService {
source: { object: 'sys_approval_request', id: raw.id },
payload: {
title: 'Your approval request breached its SLA',
- message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
+ body: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
actionUrl: '/system/approvals',
},
});
diff --git a/packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts b/packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts
new file mode 100644
index 00000000000..fdd18e49945
--- /dev/null
+++ b/packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts
@@ -0,0 +1,190 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// An approval notification reaches its recipient WITH its text.
+//
+// Every approval notification arrived as a title over an empty body. The
+// approvals service put its text in `payload.message`, and the messaging
+// service builds the delivered notification from `payload.title` and
+// `payload.body` only — the field its `EmitInput` documents — so the comment,
+// the request-info question and every escalation line were dropped on the way
+// to the inbox: `GET /api/v1/notifications` answered `body: ""` under a
+// correct title. The flow `notify` node and the @mention producer already send
+// `body`; approvals was the one producer speaking another dialect.
+//
+// The fix is at the producer, with no alias in messaging: one field, as
+// documented. This pins it at the door a recipient actually reads, on a booted
+// app with the real approvals + messaging + REST chain:
+//
+// ⭐ a submitter's comment is read back as the BODY of the approver's
+// `approval.comment` notification;
+// ⭐ an approver's request-info question is read back as the BODY of the
+// submitter's `approval.request_info` notification;
+// ⭐ each is the ONE notification of its topic in that inbox and carries its
+// title — so an empty body is a body that lost its text, never a
+// notification that never arrived (the control the defect needed: its
+// title was always there).
+//
+// The fixture is owned by this pin and kept inline: one object whose create
+// opens a request routed to a `user` approver authored as an EMAIL, so the
+// approver is a real account reached through the address an author writes.
+// Messaging runs its inline fan-out (`reliableDelivery: false`) so the inbox
+// row exists when the thread call returns; the outbox path builds the same
+// notification from the same `payload.body` (`messaging-service.ts` /
+// `dispatcher.ts`), and the producer-side pin across the service's call sites
+// is `plugin-approvals/src/approval-notification-body.integration.test.ts`.
+
+import { describe, it, expect } from 'vitest';
+import { bootStack } from '@objectstack/verify';
+import { defineStack, defineFlow } from '@objectstack/spec';
+import { ObjectSchema, Field } from '@objectstack/spec/data';
+import { PermissionSetSchema } from '@objectstack/spec/security';
+import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
+import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals';
+import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
+import { MessagingServicePlugin } from '@objectstack/service-messaging';
+
+const OBJECT = 'anb_request';
+const SUBMITTER = 'anb-submitter@example.com';
+const REVIEWER = 'anb-reviewer@example.com';
+
+/** The text each side writes — distinct, so neither can pass for the other. */
+const COMMENT = 'The signed contract is attached to the record now.';
+const QUESTION = 'Which cost centre should this be booked against?';
+
+const AnbRequest = ObjectSchema.create({
+ name: OBJECT,
+ label: 'Notification Body Request',
+ pluralLabel: 'Notification Body Requests',
+ sharingModel: 'public_read_write',
+ fields: {
+ name: Field.text({ label: 'Name', required: true }),
+ },
+});
+
+const AnbFlow = defineFlow({
+ name: 'anb_flow',
+ label: 'Notification Body Flow',
+ description: 'Fires on insert and routes to one user approver, authored as an email.',
+ type: 'autolaunched',
+ status: 'active',
+ nodes: [
+ { id: 'start', type: 'start', label: 'On Create', config: { objectName: OBJECT, triggerType: 'record-after-create' } },
+ {
+ id: 'review',
+ type: 'approval',
+ label: 'Review',
+ config: { approvers: [{ type: 'user', value: REVIEWER }], behavior: 'first_response' },
+ },
+ { id: 'approved', type: 'end', label: 'Approved' },
+ { id: 'rejected', type: 'end', label: 'Rejected' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'review' },
+ { id: 'e2', source: 'review', target: 'approved', label: 'approve' },
+ { id: 'e3', source: 'review', target: 'rejected', label: 'reject' },
+ ],
+});
+
+const anbStack = defineStack({
+ manifest: {
+ id: 'com.dogfood.approval-notification-body',
+ namespace: 'anb',
+ version: '0.0.0',
+ type: 'app',
+ name: 'Approval Notification Body Fixture',
+ description: 'One object, one flow routed to an email-authored user approver.',
+ },
+ // ADR-0097: the `record-after-create` start node needs the trigger pair
+ // declared; the pin mounts both explicitly.
+ requires: ['automation', 'triggers'],
+ objects: [AnbRequest],
+ flows: [AnbFlow],
+});
+
+/** Every fresh member resolves to create + read on the request object only. */
+const anbMemberSet = PermissionSetSchema.parse({
+ name: 'anb_member',
+ label: 'Notification Body Member — create + read on anb_request only',
+ objects: {
+ [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false },
+ },
+});
+
+interface InboxRow {
+ id: string;
+ type: string;
+ title: string;
+ body: string;
+}
+
+describe('an approval notification reaches its recipient with its text in the body', () => {
+ it(
+ "a comment and a request-info question are read back from the recipient's GET /api/v1/notifications",
+ async () => {
+ const stack = await bootStack(anbStack as unknown as Parameters[0], {
+ automation: true,
+ security: new SecurityPlugin({
+ defaultPermissionSets: [...securityDefaultPermissionSets, anbMemberSet],
+ fallbackPermissionSet: anbMemberSet.name,
+ }),
+ extraPlugins: [
+ new MessagingServicePlugin({ reliableDelivery: false }),
+ new RecordChangeTriggerPlugin(),
+ new ApprovalsServicePlugin(),
+ ],
+ });
+ try {
+ const submitterToken = await stack.signUp(SUBMITTER);
+ const reviewerToken = await stack.signUp(REVIEWER);
+
+ const created = await stack.apiAs(submitterToken, 'POST', `/data/${OBJECT}`, { name: 'contract' });
+ expect(created.status).toBe(201);
+
+ // The request opened, on the email slot — a missing request would
+ // otherwise read below as "no notification", not as the defect.
+ const pending = await stack.apiAs(reviewerToken, 'GET', `/approvals/requests?${new URLSearchParams({
+ status: 'pending', approverId: REVIEWER,
+ })}`);
+ expect(pending.status).toBe(200);
+ const rows = ((await pending.json()) as { data: Array<{ id: string; pending_approvers?: string[] }> }).data;
+ expect(rows.map((r) => r.pending_approvers)).toEqual([[REVIEWER]]);
+ const requestId = rows[0].id;
+
+ const inboxOf = async (token: string, topic: string): Promise => {
+ const res = await stack.apiAs(token, 'GET', `/notifications?${new URLSearchParams({ type: topic })}`);
+ expect(res.status, `GET /notifications?type=${topic}`).toBe(200);
+ const json = (await res.json()) as { data: { notifications: InboxRow[] } };
+ return json.data.notifications;
+ };
+
+ // ⭐ The submitter comments; the approver's notification carries it.
+ const commented = await stack.apiAs(submitterToken, 'POST', `/approvals/requests/${requestId}/comment`, {
+ comment: COMMENT,
+ });
+ expect(commented.status).toBe(200);
+ const toReviewer = await inboxOf(reviewerToken, 'approval.comment');
+ expect(toReviewer.length, "the approver's inbox holds the one comment notification").toBe(1);
+ expect(toReviewer[0].title).not.toBe('');
+ expect(toReviewer[0].body, "the comment is the notification's body").toBe(COMMENT);
+
+ // ⭐ The approver asks; the submitter's notification carries the question.
+ const asked = await stack.apiAs(reviewerToken, 'POST', `/approvals/requests/${requestId}/request-info`, {
+ comment: QUESTION,
+ });
+ expect(asked.status).toBe(200);
+ const toSubmitter = await inboxOf(submitterToken, 'approval.request_info');
+ expect(toSubmitter.length, "the submitter's inbox holds the one request-info notification").toBe(1);
+ expect(toSubmitter[0].title).not.toBe('');
+ expect(toSubmitter[0].body, "the question is the notification's body").toBe(QUESTION);
+
+ // Each side reads only what was addressed to it: the submitter wrote
+ // the comment and is not its recipient, and the reverse.
+ expect(await inboxOf(submitterToken, 'approval.comment')).toEqual([]);
+ expect(await inboxOf(reviewerToken, 'approval.request_info')).toEqual([]);
+ } finally {
+ await stack.stop();
+ }
+ },
+ 120_000,
+ );
+});