Skip to content

Commit f243727

Browse files
baozhoutaoclaude
authored andcommitted
remove(plugin-audit): drop kernel-built-in assignment notifications (#3403)
Deciding that an owner/assignee change warrants a bell is a business policy, not a platform default. The kernel notifier guessed "who is the assignee" from field names (owner_id, assigned_to, …), which misfired on system records like sys_file and spammed users with "…assigned to you" noise on file uploads (#3402). Removes writeAssignmentNotifications, the OWNER_FIELDS heuristic, and the messages.assignedToYou translation key (en/zh-CN/ja-JP/es-ES) plus the two assignment test cases; adds a regression test asserting no collab.assignment emit on an owner-field write. Assignment notifications now live in user-space automation (record-after-update + a notify node); see the showcase_task_assigned_notify flow. Unaffected: sys_audit_log / sys_activity capture; @mention notifications (collab.mention); service-messaging owner_of: audience + DEFAULT_OWNER_FIELDS (a separate, caller-requested mechanism — comment updated). Design docs get a deprecation note (history not rewritten). Changeset carries the FROM→TO migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 54f479a commit f243727

7 files changed

Lines changed: 83 additions & 136 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
remove(plugin-audit): drop the kernel's built-in assignment notifications; move the policy to user-space automation (#3403)
6+
7+
**Breaking (behavioral).** `plugin-audit` no longer emits a `collab.assignment`
8+
notification when an owner/assignee field changes on a record. Deciding that an
9+
assignment warrants a bell is a business policy, not a platform default — the
10+
kernel version guessed "who is the assignee" from field names (`owner_id`,
11+
`assigned_to`, `assignee_id`, `owner`, `assignee`), which misfired on system
12+
records like `sys_file` and spammed users with "…assigned to you" noise on file
13+
uploads (#3402).
14+
15+
**What was removed:** the `writeAssignmentNotifications` writer, the `OWNER_FIELDS`
16+
heuristic, and the `messages.assignedToYou` translation key (en / zh-CN / ja-JP /
17+
es-ES). **Unaffected:** `sys_audit_log` / `sys_activity` capture, and `@mention`
18+
notifications (`collab.mention`) — those remain platform behavior. The
19+
`owner_of:` messaging audience and `service-messaging`'s `DEFAULT_OWNER_FIELDS`
20+
are a separate, caller-requested mechanism and are unchanged.
21+
22+
**FROM → TO migration.** If you relied on the automatic bell, configure an
23+
automation flow on the target object (`record-after-update` / `record-after-create`
24+
trigger + a `notify` node). The `condition` can read the pre-update row via
25+
`previous`, and `notify`'s `recipients` / `title` / `actionUrl` all interpolate
26+
record fields. Ready-made example: `showcase_task_assigned_notify` in
27+
`examples/app-showcase/src/automation/flows/index.ts`:
28+
29+
```ts
30+
{ id: 'start', type: 'start', config: {
31+
objectName: 'your_object',
32+
triggerType: 'record-after-update',
33+
condition: 'assignee != previous.assignee',
34+
} },
35+
{ id: 'notify_assignee', type: 'notify', config: {
36+
topic: 'task.assigned',
37+
recipients: ['{record.assignee}'],
38+
channels: ['inbox'],
39+
title: 'New assignment: {record.title}',
40+
actionUrl: '/your_object/{record.id}',
41+
} },
42+
```
43+
44+
Notes on parity: the flow template renders a single language (the kernel version
45+
localized the title to the recipient's locale); a flow fires on every real change
46+
(the `previous` condition already gates that) and, unless you add an actor guard,
47+
also notifies self-assignments — the kernel version suppressed those.

docs/design/notification-platform-convergence.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@
1919

2020
> **Single ingress.** Every producer calls `NotificationService.emit(...)`. **No producer writes a per-user inbox/materialization row directly.** The in-app inbox is a *materialization of delivery*.
2121
22+
> **⚠️ Superseded (framework#3403, 2026-07):** the **assignment** notifier described
23+
> throughout this doc was *removed* from the kernel, not merely re-routed. Deciding
24+
> that an owner/assignee change warrants a bell is a business policy; the field-name
25+
> heuristic (`owner_id` et al.) misfired on system records like `sys_file`
26+
> (framework#3402). Assignment notifications are now user-space automation (a
27+
> `record-after-update` trigger + a `notify` node). The `@mention` convergence below
28+
> still stands. Assignment mentions in the sections that follow are historical.
29+
2230
Current violations to remove:
2331
- `plugin-audit/src/audit-writers.ts` writes `sys_notification` directly (`@mention`, assignment) — re-route to `emit()`.
2432
- `service-messaging` inbox channel writes `sys_inbox_message` directly from the `notify` node — keep the channel, but it must run **after** the pipeline (event → resolve → deliver), not as the producer.

docs/handoff/adr-0030-notification-convergence.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
(Console bell) cut-over, mark-read write path, incremental channels, topic
77
catalog, and hardening remain. Date: 2026-06-01.
88

9+
> **⚠️ Superseded in part (framework#3403, 2026-07):** the **assignment**
10+
> (`collab.assignment`) producer documented below was later *removed* from the
11+
> kernel — assignment notifications are a business policy, and the field-name
12+
> heuristic that drove them misfired on system records (framework#3402). They now
13+
> live in user-space automation (`record-after-update` + a `notify` node). The
14+
> `@mention` (`collab.mention`) producer and the single-ingress model are unchanged.
15+
> Assignment references below are historical.
16+
917
---
1018

1119
## What shipped in this repo (framework)

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

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -661,18 +661,6 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti
661661
return { fire, emits };
662662
}
663663

664-
it('localizes the assignment notification title to the recipient locale', async () => {
665-
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
666-
await fire('afterInsert', {
667-
...insertCtx(),
668-
result: { id: 'q-1', name: 'OC-00001', owner_id: 'user-2' },
669-
});
670-
const assignment = emits.find((e) => e.topic === 'collab.assignment');
671-
expect(assignment).toBeDefined();
672-
expect(assignment.audience).toEqual(['user-2']);
673-
expect(assignment.payload.title).toBe('人员资质 "OC-00001" 已分配给你');
674-
});
675-
676664
it('localizes the @mention notification title to the recipient locale', async () => {
677665
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
678666
await fire('afterInsert', {
@@ -694,20 +682,22 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti
694682
expect(mention.payload.title).toBe('Alice 提到了你');
695683
});
696684

697-
it('falls back to an English title with the authored object label when i18n misses', async () => {
685+
// framework#3403 — the kernel no longer emits assignment notifications from
686+
// owner/assignee field changes (that policy moved to user-space automation
687+
// flows). Setting owner_id must NOT produce a `collab.assignment` emit.
688+
it('does NOT emit an assignment notification when an owner field is set (framework#3403)', async () => {
698689
const { fire, emits } = setupWithMessaging(
699690
undefined,
700691
undefined,
701692
{ crm_lead: { label: 'Lead' } },
702-
{ ...SINGLE_TENANT, crm_lead: ['id', 'name'] },
693+
{ ...SINGLE_TENANT, crm_lead: ['id', 'name', 'owner_id'] },
703694
);
704695
await fire('afterInsert', {
705696
object: 'crm_lead',
706697
input: { id: 'l-1' },
707698
result: { id: 'l-1', name: 'Acme', owner_id: 'user-2' },
708699
session: { tenantId: 'org-1', userId: 'user-1' },
709700
});
710-
const assignment = emits.find((e) => e.topic === 'collab.assignment');
711-
expect(assignment.payload.title).toBe('Lead "Acme" assigned to you');
701+
expect(emits.find((e) => e.topic === 'collab.assignment')).toBeUndefined();
712702
});
713703
});

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

Lines changed: 10 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -612,35 +612,17 @@ export function installAuditWriters(
612612
const sys = api.sudo();
613613
await sys.object('sys_audit_log').create(auditRow);
614614
if (activitiesEnabled) await sys.object('sys_activity').create(activityRow);
615-
// M10.8 / ADR-0030: notify the assignee. Best-effort; never throws into
616-
// the user-facing CRUD path. Goes through the messaging single ingress
617-
// (`emit`) — the inbox channel materializes the bell row — rather than
618-
// writing `sys_notification` directly. If owner_id / assigned_to was
619-
// newly set (or changed to a different user) on a non-system record, the
620-
// recipient sees "Lead X was assigned to you" without polling.
615+
// Assignment notifications are NOT emitted here (framework#3403). Deciding
616+
// that an owner/assignee change warrants a bell is a business policy, not a
617+
// platform default — the kernel version guessed "who is the assignee" from
618+
// field names (`owner_id` et al.), which misfired on system records like
619+
// `sys_file` (framework#3402). Applications now opt in per object with an
620+
// automation flow (`record-after-update` + a `notify` node); see the
621+
// `showcase_task_assigned_notify` flow for a ready-made example.
621622
//
622-
// (Comment mentions are handled separately by the sys_comment hook below
623-
// since SKIP_OBJECTS excludes it from this writer.)
624-
await writeAssignmentNotifications(getMessaging(), {
625-
object: ctx.object,
626-
recordId: recordId ?? null,
627-
label,
628-
action,
629-
before,
630-
after,
631-
actorId: userId ?? null,
632-
tenantId: tenantId ?? null,
633-
// Localize the bell title to the RECIPIENT's locale (they read it),
634-
// not the acting user's — same key shapes as the activity summaries.
635-
makeTitle: async (recipientId) => {
636-
const rTranslate = translateWith(await resolveWriteLocale(tenantId, recipientId));
637-
const objectLabel = displayLabelFor(ctx.object, rTranslate);
638-
return (
639-
rTranslate('messages.assignedToYou', { object: objectLabel, label }) ??
640-
`${objectLabel} "${label}" assigned to you`
641-
);
642-
},
643-
});
623+
// (Comment @mention notifications remain a platform behavior — they are
624+
// handled separately by the sys_comment hook below, since SKIP_OBJECTS
625+
// excludes it from this writer.)
644626
} catch (err) {
645627
// Log via engine logger if available, but never throw.
646628
try { (engine as any).logger?.warn?.('Audit write failed', { object: ctx.object, action, err: String((err as any)?.message ?? err) }); } catch {}
@@ -785,92 +767,5 @@ export function installAuditWriters(
785767
engine.registerHook('afterInsert', writeCommentMentions, { packageId });
786768
}
787769

788-
/**
789-
* Identify the assignee/owner field of a record. We accept several
790-
* conventional names so this works across CRM-style objects (owner_id,
791-
* assigned_to) and platform objects (recipient_id is handled separately).
792-
*/
793-
const OWNER_FIELDS = ['owner_id', 'assigned_to', 'assignee_id', 'owner', 'assignee'];
794-
795-
function pickOwner(rec: any): string | null {
796-
if (!rec || typeof rec !== 'object') return null;
797-
for (const f of OWNER_FIELDS) {
798-
const v = rec[f];
799-
if (typeof v === 'string' && v.length > 0) return v;
800-
}
801-
return null;
802-
}
803-
804-
async function writeAssignmentNotifications(
805-
messaging: MessagingEmitSurface | undefined,
806-
params: {
807-
object: string;
808-
recordId: string | null;
809-
label: string;
810-
action: 'create' | 'update' | 'delete';
811-
before: any;
812-
after: any;
813-
actorId: string | null;
814-
tenantId: string | null;
815-
/**
816-
* Build the (recipient-locale-localized) notification title. Optional —
817-
* absent or throwing, the English literal below is used, with the raw
818-
* object API name (pre-#3039 behavior).
819-
*/
820-
makeTitle?: (recipientId: string) => Promise<string>;
821-
},
822-
): Promise<void> {
823-
if (!messaging) return; // no pipeline installed → no assignment notifications
824-
if (params.action === 'delete') return;
825-
if (!params.recordId) return;
826-
827-
const newOwner = pickOwner(params.after);
828-
const oldOwner = pickOwner(params.before);
829-
if (!newOwner) return;
830-
if (params.action === 'update' && newOwner === oldOwner) return;
831-
if (newOwner === params.actorId) return; // self-assignment is silent
832-
833-
let title = `${params.object} "${params.label}" assigned to you`;
834-
if (params.makeTitle) {
835-
try {
836-
title = await params.makeTitle(newOwner);
837-
} catch {
838-
/* keep the English fallback */
839-
}
840-
}
841-
842-
try {
843-
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
844-
// channel materializes the bell row + a delivered receipt. organizationId
845-
// is propagated so the recipient (same tenant as the action) sees the
846-
// materialized row through RLS.
847-
// Dedup only a true double-fire of the SAME write: scope the key by the
848-
// record's write-version (updated_at). Without a version component the key
849-
// would be permanent and a legitimate re-assignment back to a prior owner
850-
// would be silently suppressed. When no version field exists, omit the key
851-
// (every assignment notifies — same as the pre-ADR-0030 direct-write path).
852-
const writeVersion =
853-
(params.after && typeof params.after === 'object'
854-
? params.after.updated_at ?? params.after.modified_at ?? params.after.updated_date
855-
: null) ?? null;
856-
await messaging.emit({
857-
topic: 'collab.assignment',
858-
audience: [newOwner],
859-
severity: 'info',
860-
source: { object: params.object, id: params.recordId },
861-
actorId: params.actorId ?? undefined,
862-
organizationId: params.tenantId ?? undefined,
863-
dedupKey: writeVersion
864-
? `collab.assignment:${params.object}:${params.recordId}:${newOwner}:${writeVersion}`
865-
: undefined,
866-
payload: {
867-
title,
868-
},
869-
});
870-
} catch {
871-
// best-effort; never throw into CRUD path
872-
}
873-
}
874-
875770
// Re-export for convenience.
876771
export type { IDataEngine };

packages/plugins/plugin-audit/src/translations/messages.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export const enMessages: Messages = {
1818
activityCreated: 'Created {{object}} "{{label}}"',
1919
activityUpdated: 'Updated {{object}} "{{label}}"',
2020
activityDeleted: 'Deleted {{object}} "{{label}}"',
21-
assignedToYou: '{{object}} "{{label}}" assigned to you',
2221
mentionedYou: '{{actor}} mentioned you',
2322
mentionedYouAnonymous: 'You were mentioned',
2423
};
@@ -27,7 +26,6 @@ export const zhCNMessages: Messages = {
2726
activityCreated: '创建了 {{object}} "{{label}}"',
2827
activityUpdated: '更新了 {{object}} "{{label}}"',
2928
activityDeleted: '删除了 {{object}} "{{label}}"',
30-
assignedToYou: '{{object}} "{{label}}" 已分配给你',
3129
mentionedYou: '{{actor}} 提到了你',
3230
mentionedYouAnonymous: '有人提到了你',
3331
};
@@ -36,7 +34,6 @@ export const jaJPMessages: Messages = {
3634
activityCreated: '{{object}}「{{label}}」を作成しました',
3735
activityUpdated: '{{object}}「{{label}}」を更新しました',
3836
activityDeleted: '{{object}}「{{label}}」を削除しました',
39-
assignedToYou: '{{object}}「{{label}}」があなたに割り当てられました',
4037
mentionedYou: '{{actor}}さんがあなたをメンションしました',
4138
mentionedYouAnonymous: 'あなたがメンションされました',
4239
};
@@ -45,7 +42,6 @@ export const esESMessages: Messages = {
4542
activityCreated: 'Creó {{object}} "{{label}}"',
4643
activityUpdated: 'Actualizó {{object}} "{{label}}"',
4744
activityDeleted: 'Eliminó {{object}} "{{label}}"',
48-
assignedToYou: 'Se te asignó {{object}} "{{label}}"',
4945
mentionedYou: '{{actor}} te mencionó',
5046
mentionedYouAnonymous: 'Te han mencionado',
5147
};

packages/services/service-messaging/src/recipient-resolver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ export const TEAM_MEMBER_OBJECT = 'sys_team_member';
2828

2929
/**
3030
* Conventional owner/assignee field names tried, in order, for `owner_of:`
31-
* audience resolution. Mirrors the audit writer's `OWNER_FIELDS`.
31+
* audience resolution. This is the sole home for the convention: the audit
32+
* writer's kernel-side assignment notifier (which mirrored this list) was
33+
* removed in framework#3403 — `owner_of:` is an explicit, caller-requested
34+
* audience, not a field-name heuristic applied to every write.
3235
*/
3336
const DEFAULT_OWNER_FIELDS = ['owner_id', 'assigned_to', 'assignee_id', 'owner', 'assignee'];
3437

0 commit comments

Comments
 (0)