From 0f029a9e29965a55dfe45a1268b98960afff1af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 22 Jul 2026 03:38:52 -0700 Subject: [PATCH] remove(plugin-audit): drop kernel-built-in assignment notifications (#3403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...precate-kernel-assignment-notifications.md | 47 +++++++ .../notification-platform-convergence.md | 8 ++ .../adr-0030-notification-convergence.md | 8 ++ .../plugin-audit/src/audit-writers.test.ts | 22 +-- .../plugins/plugin-audit/src/audit-writers.ts | 125 ++---------------- .../plugin-audit/src/translations/messages.ts | 4 - .../src/recipient-resolver.ts | 5 +- 7 files changed, 83 insertions(+), 136 deletions(-) create mode 100644 .changeset/deprecate-kernel-assignment-notifications.md diff --git a/.changeset/deprecate-kernel-assignment-notifications.md b/.changeset/deprecate-kernel-assignment-notifications.md new file mode 100644 index 0000000000..b6b1f0b73b --- /dev/null +++ b/.changeset/deprecate-kernel-assignment-notifications.md @@ -0,0 +1,47 @@ +--- +"@objectstack/plugin-audit": minor +--- + +remove(plugin-audit): drop the kernel's built-in assignment notifications; move the policy to user-space automation (#3403) + +**Breaking (behavioral).** `plugin-audit` no longer emits a `collab.assignment` +notification when an owner/assignee field changes on a record. Deciding that an +assignment warrants a bell is a business policy, not a platform default — the +kernel version guessed "who is the assignee" from field names (`owner_id`, +`assigned_to`, `assignee_id`, `owner`, `assignee`), which misfired on system +records like `sys_file` and spammed users with "…assigned to you" noise on file +uploads (#3402). + +**What was removed:** the `writeAssignmentNotifications` writer, the `OWNER_FIELDS` +heuristic, and the `messages.assignedToYou` translation key (en / zh-CN / ja-JP / +es-ES). **Unaffected:** `sys_audit_log` / `sys_activity` capture, and `@mention` +notifications (`collab.mention`) — those remain platform behavior. The +`owner_of:` messaging audience and `service-messaging`'s `DEFAULT_OWNER_FIELDS` +are a separate, caller-requested mechanism and are unchanged. + +**FROM → TO migration.** If you relied on the automatic bell, configure an +automation flow on the target object (`record-after-update` / `record-after-create` +trigger + a `notify` node). The `condition` can read the pre-update row via +`previous`, and `notify`'s `recipients` / `title` / `actionUrl` all interpolate +record fields. Ready-made example: `showcase_task_assigned_notify` in +`examples/app-showcase/src/automation/flows/index.ts`: + +```ts +{ id: 'start', type: 'start', config: { + objectName: 'your_object', + triggerType: 'record-after-update', + condition: 'assignee != previous.assignee', +} }, +{ id: 'notify_assignee', type: 'notify', config: { + topic: 'task.assigned', + recipients: ['{record.assignee}'], + channels: ['inbox'], + title: 'New assignment: {record.title}', + actionUrl: '/your_object/{record.id}', +} }, +``` + +Notes on parity: the flow template renders a single language (the kernel version +localized the title to the recipient's locale); a flow fires on every real change +(the `previous` condition already gates that) and, unless you add an actor guard, +also notifies self-assignments — the kernel version suppressed those. diff --git a/docs/design/notification-platform-convergence.md b/docs/design/notification-platform-convergence.md index 1db9fa01cf..d463282762 100644 --- a/docs/design/notification-platform-convergence.md +++ b/docs/design/notification-platform-convergence.md @@ -19,6 +19,14 @@ > **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*. +> **⚠️ Superseded (framework#3403, 2026-07):** the **assignment** notifier described +> throughout this doc was *removed* from the kernel, not merely re-routed. Deciding +> that an owner/assignee change warrants a bell is a business policy; the field-name +> heuristic (`owner_id` et al.) misfired on system records like `sys_file` +> (framework#3402). Assignment notifications are now user-space automation (a +> `record-after-update` trigger + a `notify` node). The `@mention` convergence below +> still stands. Assignment mentions in the sections that follow are historical. + Current violations to remove: - `plugin-audit/src/audit-writers.ts` writes `sys_notification` directly (`@mention`, assignment) — re-route to `emit()`. - `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. diff --git a/docs/handoff/adr-0030-notification-convergence.md b/docs/handoff/adr-0030-notification-convergence.md index 5a73584d96..6cc14ad572 100644 --- a/docs/handoff/adr-0030-notification-convergence.md +++ b/docs/handoff/adr-0030-notification-convergence.md @@ -6,6 +6,14 @@ (Console bell) cut-over, mark-read write path, incremental channels, topic catalog, and hardening remain. Date: 2026-06-01. +> **⚠️ Superseded in part (framework#3403, 2026-07):** the **assignment** +> (`collab.assignment`) producer documented below was later *removed* from the +> kernel — assignment notifications are a business policy, and the field-name +> heuristic that drove them misfired on system records (framework#3402). They now +> live in user-space automation (`record-after-update` + a `notify` node). The +> `@mention` (`collab.mention`) producer and the single-ingress model are unchanged. +> Assignment references below are historical. + --- ## What shipped in this repo (framework) diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index dca786734e..40c5b79179 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -661,18 +661,6 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti return { fire, emits }; } - it('localizes the assignment notification title to the recipient locale', async () => { - const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n()); - await fire('afterInsert', { - ...insertCtx(), - result: { id: 'q-1', name: 'OC-00001', owner_id: 'user-2' }, - }); - const assignment = emits.find((e) => e.topic === 'collab.assignment'); - expect(assignment).toBeDefined(); - expect(assignment.audience).toEqual(['user-2']); - expect(assignment.payload.title).toBe('人员资质 "OC-00001" 已分配给你'); - }); - it('localizes the @mention notification title to the recipient locale', async () => { const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n()); await fire('afterInsert', { @@ -694,12 +682,15 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti expect(mention.payload.title).toBe('Alice 提到了你'); }); - it('falls back to an English title with the authored object label when i18n misses', async () => { + // framework#3403 — the kernel no longer emits assignment notifications from + // owner/assignee field changes (that policy moved to user-space automation + // flows). Setting owner_id must NOT produce a `collab.assignment` emit. + it('does NOT emit an assignment notification when an owner field is set (framework#3403)', async () => { const { fire, emits } = setupWithMessaging( undefined, undefined, { crm_lead: { label: 'Lead' } }, - { ...SINGLE_TENANT, crm_lead: ['id', 'name'] }, + { ...SINGLE_TENANT, crm_lead: ['id', 'name', 'owner_id'] }, ); await fire('afterInsert', { object: 'crm_lead', @@ -707,7 +698,6 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti result: { id: 'l-1', name: 'Acme', owner_id: 'user-2' }, session: { tenantId: 'org-1', userId: 'user-1' }, }); - const assignment = emits.find((e) => e.topic === 'collab.assignment'); - expect(assignment.payload.title).toBe('Lead "Acme" assigned to you'); + expect(emits.find((e) => e.topic === 'collab.assignment')).toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index c1d411740f..ed4d4d7870 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -612,35 +612,17 @@ export function installAuditWriters( const sys = api.sudo(); await sys.object('sys_audit_log').create(auditRow); if (activitiesEnabled) await sys.object('sys_activity').create(activityRow); - // M10.8 / ADR-0030: notify the assignee. Best-effort; never throws into - // the user-facing CRUD path. Goes through the messaging single ingress - // (`emit`) — the inbox channel materializes the bell row — rather than - // writing `sys_notification` directly. If owner_id / assigned_to was - // newly set (or changed to a different user) on a non-system record, the - // recipient sees "Lead X was assigned to you" without polling. + // Assignment notifications are NOT emitted here (framework#3403). Deciding + // that an owner/assignee change warrants a bell is a business policy, not a + // platform default — the kernel version guessed "who is the assignee" from + // field names (`owner_id` et al.), which misfired on system records like + // `sys_file` (framework#3402). Applications now opt in per object with an + // automation flow (`record-after-update` + a `notify` node); see the + // `showcase_task_assigned_notify` flow for a ready-made example. // - // (Comment mentions are handled separately by the sys_comment hook below - // since SKIP_OBJECTS excludes it from this writer.) - await writeAssignmentNotifications(getMessaging(), { - object: ctx.object, - recordId: recordId ?? null, - label, - action, - before, - after, - actorId: userId ?? null, - tenantId: tenantId ?? null, - // Localize the bell title to the RECIPIENT's locale (they read it), - // not the acting user's — same key shapes as the activity summaries. - makeTitle: async (recipientId) => { - const rTranslate = translateWith(await resolveWriteLocale(tenantId, recipientId)); - const objectLabel = displayLabelFor(ctx.object, rTranslate); - return ( - rTranslate('messages.assignedToYou', { object: objectLabel, label }) ?? - `${objectLabel} "${label}" assigned to you` - ); - }, - }); + // (Comment @mention notifications remain a platform behavior — they are + // handled separately by the sys_comment hook below, since SKIP_OBJECTS + // excludes it from this writer.) } catch (err) { // Log via engine logger if available, but never throw. 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( engine.registerHook('afterInsert', writeCommentMentions, { packageId }); } -/** - * Identify the assignee/owner field of a record. We accept several - * conventional names so this works across CRM-style objects (owner_id, - * assigned_to) and platform objects (recipient_id is handled separately). - */ -const OWNER_FIELDS = ['owner_id', 'assigned_to', 'assignee_id', 'owner', 'assignee']; - -function pickOwner(rec: any): string | null { - if (!rec || typeof rec !== 'object') return null; - for (const f of OWNER_FIELDS) { - const v = rec[f]; - if (typeof v === 'string' && v.length > 0) return v; - } - return null; -} - -async function writeAssignmentNotifications( - messaging: MessagingEmitSurface | undefined, - params: { - object: string; - recordId: string | null; - label: string; - action: 'create' | 'update' | 'delete'; - before: any; - after: any; - actorId: string | null; - tenantId: string | null; - /** - * Build the (recipient-locale-localized) notification title. Optional — - * absent or throwing, the English literal below is used, with the raw - * object API name (pre-#3039 behavior). - */ - makeTitle?: (recipientId: string) => Promise; - }, -): Promise { - if (!messaging) return; // no pipeline installed → no assignment notifications - if (params.action === 'delete') return; - if (!params.recordId) return; - - const newOwner = pickOwner(params.after); - const oldOwner = pickOwner(params.before); - if (!newOwner) return; - if (params.action === 'update' && newOwner === oldOwner) return; - if (newOwner === params.actorId) return; // self-assignment is silent - - let title = `${params.object} "${params.label}" assigned to you`; - if (params.makeTitle) { - try { - title = await params.makeTitle(newOwner); - } catch { - /* keep the English fallback */ - } - } - - try { - // ADR-0030 single ingress — emit() writes the L2 event and the inbox - // channel materializes the bell row + a delivered receipt. organizationId - // is propagated so the recipient (same tenant as the action) sees the - // materialized row through RLS. - // Dedup only a true double-fire of the SAME write: scope the key by the - // record's write-version (updated_at). Without a version component the key - // would be permanent and a legitimate re-assignment back to a prior owner - // would be silently suppressed. When no version field exists, omit the key - // (every assignment notifies — same as the pre-ADR-0030 direct-write path). - const writeVersion = - (params.after && typeof params.after === 'object' - ? params.after.updated_at ?? params.after.modified_at ?? params.after.updated_date - : null) ?? null; - await messaging.emit({ - topic: 'collab.assignment', - audience: [newOwner], - severity: 'info', - source: { object: params.object, id: params.recordId }, - actorId: params.actorId ?? undefined, - organizationId: params.tenantId ?? undefined, - dedupKey: writeVersion - ? `collab.assignment:${params.object}:${params.recordId}:${newOwner}:${writeVersion}` - : undefined, - payload: { - title, - }, - }); - } catch { - // best-effort; never throw into CRUD path - } -} - // Re-export for convenience. export type { IDataEngine }; diff --git a/packages/plugins/plugin-audit/src/translations/messages.ts b/packages/plugins/plugin-audit/src/translations/messages.ts index ad6edc7d4c..1ad34b6f07 100644 --- a/packages/plugins/plugin-audit/src/translations/messages.ts +++ b/packages/plugins/plugin-audit/src/translations/messages.ts @@ -18,7 +18,6 @@ export const enMessages: Messages = { activityCreated: 'Created {{object}} "{{label}}"', activityUpdated: 'Updated {{object}} "{{label}}"', activityDeleted: 'Deleted {{object}} "{{label}}"', - assignedToYou: '{{object}} "{{label}}" assigned to you', mentionedYou: '{{actor}} mentioned you', mentionedYouAnonymous: 'You were mentioned', }; @@ -27,7 +26,6 @@ export const zhCNMessages: Messages = { activityCreated: '创建了 {{object}} "{{label}}"', activityUpdated: '更新了 {{object}} "{{label}}"', activityDeleted: '删除了 {{object}} "{{label}}"', - assignedToYou: '{{object}} "{{label}}" 已分配给你', mentionedYou: '{{actor}} 提到了你', mentionedYouAnonymous: '有人提到了你', }; @@ -36,7 +34,6 @@ export const jaJPMessages: Messages = { activityCreated: '{{object}}「{{label}}」を作成しました', activityUpdated: '{{object}}「{{label}}」を更新しました', activityDeleted: '{{object}}「{{label}}」を削除しました', - assignedToYou: '{{object}}「{{label}}」があなたに割り当てられました', mentionedYou: '{{actor}}さんがあなたをメンションしました', mentionedYouAnonymous: 'あなたがメンションされました', }; @@ -45,7 +42,6 @@ export const esESMessages: Messages = { activityCreated: 'Creó {{object}} "{{label}}"', activityUpdated: 'Actualizó {{object}} "{{label}}"', activityDeleted: 'Eliminó {{object}} "{{label}}"', - assignedToYou: 'Se te asignó {{object}} "{{label}}"', mentionedYou: '{{actor}} te mencionó', mentionedYouAnonymous: 'Te han mencionado', }; diff --git a/packages/services/service-messaging/src/recipient-resolver.ts b/packages/services/service-messaging/src/recipient-resolver.ts index 84a0d7aeda..1a68af6689 100644 --- a/packages/services/service-messaging/src/recipient-resolver.ts +++ b/packages/services/service-messaging/src/recipient-resolver.ts @@ -28,7 +28,10 @@ export const TEAM_MEMBER_OBJECT = 'sys_team_member'; /** * Conventional owner/assignee field names tried, in order, for `owner_of:` - * audience resolution. Mirrors the audit writer's `OWNER_FIELDS`. + * audience resolution. This is the sole home for the convention: the audit + * writer's kernel-side assignment notifier (which mirrored this list) was + * removed in framework#3403 — `owner_of:` is an explicit, caller-requested + * audience, not a field-name heuristic applied to every write. */ const DEFAULT_OWNER_FIELDS = ['owner_id', 'assigned_to', 'assignee_id', 'owner', 'assignee'];