Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/deprecate-kernel-assignment-notifications.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/design/notification-platform-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs/handoff/adr-0030-notification-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 6 additions & 16 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -694,20 +682,22 @@ 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',
input: { id: 'l-1' },
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();
});
});
125 changes: 10 additions & 115 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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<string>;
},
): Promise<void> {
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 };
4 changes: 0 additions & 4 deletions packages/plugins/plugin-audit/src/translations/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand All @@ -27,7 +26,6 @@ export const zhCNMessages: Messages = {
activityCreated: '创建了 {{object}} "{{label}}"',
activityUpdated: '更新了 {{object}} "{{label}}"',
activityDeleted: '删除了 {{object}} "{{label}}"',
assignedToYou: '{{object}} "{{label}}" 已分配给你',
mentionedYou: '{{actor}} 提到了你',
mentionedYouAnonymous: '有人提到了你',
};
Expand All @@ -36,7 +34,6 @@ export const jaJPMessages: Messages = {
activityCreated: '{{object}}「{{label}}」を作成しました',
activityUpdated: '{{object}}「{{label}}」を更新しました',
activityDeleted: '{{object}}「{{label}}」を削除しました',
assignedToYou: '{{object}}「{{label}}」があなたに割り当てられました',
mentionedYou: '{{actor}}さんがあなたをメンションしました',
mentionedYouAnonymous: 'あなたがメンションされました',
};
Expand All @@ -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',
};
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down