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
30 changes: 30 additions & 0 deletions .changeset/23284-notification-takes-record-organization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/objectql': minor
'@objectstack/service-messaging': minor
'@objectstack/service-automation': minor
'@objectstack/plugin-approvals': minor
---

fix(objectql,service-messaging,service-automation,plugin-approvals)!: a notification carries the organization of what it is about, and one no organization owns is refused instead of stored with `organization_id` NULL

Clause-②: no (narrowing) — notification rows gain the organization they should already carry; what narrows is an emit that names no organization on an install where none is derivable, which the engine now refuses where it used to store the event row with `organization_id` NULL.

<!-- adr-0087: not-required (no-migration-prescription) A runtime narrowing of which notification writes the engine accepts, not a metadata change. No spec key, authorable spelling, export, option, config field or stored shape is added, removed, renamed or re-shaped, and no stored row is rewritten: rows already stored with organization_id NULL keep it until the v18 ownership ceremony attributes them, which already lists sys_notification with the attribution fate. The only type change is an additive optional `organizationId` on the input of `ApprovalMessagingSurface.emit`, the approvals service's messaging surface: the interface is not listed in the package index, but it is published surface, reachable through the exported `ApprovalServiceOptions.messaging` and the public `ApprovalService.attachMessaging`, so it is an additive widening that no existing implementation has to change for. The other categories are closed on facts: the four packages publish (not unpublished); no ADR-0087 id covers notification ownership and this diff adds none (not registered / already-registered); nothing is removed or narrowed on an interface or type (not runtime-interface-only / type-surface-only); and no runtime export is retired (not runtime-export-retired). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

Every notification emitted from a system-context run — a scheduled flow, a record change written by a system context, an approval node — was stored in `sys_notification` with `organization_id` NULL, so reports and cleanup that filter by organization did not see it. Each emit also logged a `[notify] no organization in scope` warning. On an install holding several organizations, the deliveries of the same emit were refused while the flow step reported success, so nothing reached the inbox.

**Who owns a notification now:**

- **The run's organization.** A run that carries one emits exactly as before.
- **Else the record it is about.** A `notify` node in a run with no organization reads the organization of the record the notification is about. That is the declared click-through record (`sourceObject` + `sourceId`), else the run's trigger record. It is read from the stored row by id, never from the record snapshot the run carries.
- **Approvals.** Every approval notification carries its request's organization: `approval.requested`, reminders, comments, send-backs, reassignments, escalations and out-of-office notices.
- **Else the owner the engine derives for a system write** (ADR-0131 D9). `sys_notification` is now in the platform-object tenancy ledger, so an emit that names no organization gets the install's sole organization under the `single` posture.
- **The rows below follow the event.** `emit()` stamps the stored event row's organization on its delivery, inbox row and receipt, on both the inline and the outbox path.

**Refused now:** where no organization is derivable (a walled posture, or a `single` install holding more than one organization), an emit that names none is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing is written for it. A `notify` step that hits the refusal fails with `notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: …` instead of succeeding. `MessagingService.emit()` rejects with the engine's error for any other producer.

**What to do when it refuses:** give the notification an owner. Name the record it is about on the `notify` node (`sourceObject` + `sourceId`), declare the scheduled flow's acting organization (`organization` on its start node), or pass `organizationId` to `emit()` from your own code.

The `[notify] no organization in scope` warning is gone, because the NULL row it predicted is no longer written. Rows already stored with `organization_id` NULL are not rewritten here. The v18 organization-ownership ceremony attributes them from their source record.
53 changes: 52 additions & 1 deletion packages/objectql/src/tenancy-by-object-classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,15 @@ const NO_TENANT_FIELD = {
} as any;

const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any;
/** ADMITTED by #23284 — the notification family's event row. */
const SYS_NOTIFICATION = { name: 'sys_notification', fields: { topic: { type: 'text' } } } as any;

async function makeEngine(opts: { posture?: string; organizations?: string[] } = {}) {
const observed: ObservedCall[] = [];
const engine = new ObjectQL();
engine.registerDriver(makeDriver(observed, opts.organizations ?? [ORG_ID]), true);
await engine.init();
for (const o of [SYS_FILE, SYS_PERMISSION_SET, SYS_UNADJUDICATED, DISPATCH_ORDER, NO_TENANT_FIELD, ORG_OBJECT]) {
for (const o of [SYS_FILE, SYS_PERMISSION_SET, SYS_UNADJUDICATED, DISPATCH_ORDER, NO_TENANT_FIELD, ORG_OBJECT, SYS_NOTIFICATION]) {
engine.registry.registerObject(o, PACKAGE_ID);
}
if (opts.posture) engine.setTenancyPostureProvider(() => opts.posture);
Expand Down Expand Up @@ -164,12 +166,19 @@ describe('#13491 the inventory — a verdict per object, never a namespace', ()
// Pinned as a LIST, not a count: the blast radius of the reclassification
// IS this set, and a silent arrival here is a behaviour change nobody
// adjudicated. Growing it is a maintainer decision, not a refactor.
//
// `sys_notification` (#23284) arrives on the maintainer's 2026-08-24
// ruling that names the table — 「11303
// sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」 —
// once its producers fill the column; its own describe block below pins
// what the admission moves.
expect(tenantScopedPlatformObjects()).toEqual([
'sys_approval_action',
'sys_approval_approver',
'sys_approval_request',
'sys_automation_run',
'sys_file',
'sys_notification',
'sys_notification_delivery',
'sys_record_share',
'sys_upload_session',
Expand Down Expand Up @@ -232,6 +241,48 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref
});
});

describe('#23284 — a notification no producer could attribute takes the owner ADR-0131 D9 derives', () => {
// The producers thread an organization when they have one (the run's, the
// record the notification is about, the approval request's). What is left —
// a notification about no record, from a run with no organization — reaches
// the engine with none, and admission is what puts it under the system-write
// rule instead of landing it with `organization_id` NULL.
it('single posture, one organization: the org-less notification takes the sole organization', async () => {
const { engine, observed } = await makeEngine({ posture: 'single' });
await engine.insert('sys_notification', { topic: 'sweep.breach', organization_id: null }, { context: SYSTEM_CTX } as any);
expect(lastWrite(observed, 'sys_notification')?.options?.tenantId).toBe(ORG_ID);
});

it('single posture, two organizations: the org-less notification is REFUSED, loudly, and never written', async () => {
const { engine, observed } = await makeEngine({ posture: 'single', organizations: [ORG_ID, 'org_second'] });
await expect(
engine.insert('sys_notification', { topic: 'sweep.breach', organization_id: null }, { context: SYSTEM_CTX } as any),
).rejects.toMatchObject({ code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', status: 500 });
expect(lastWrite(observed, 'sys_notification')).toBeUndefined();
});

it.each(['isolated', 'group'] as const)(
'%s posture: the org-less notification is REFUSED, loudly, and never written',
async (posture) => {
const { engine, observed } = await makeEngine({ posture });
await expect(
engine.insert('sys_notification', { topic: 'sweep.breach', organization_id: null }, { context: SYSTEM_CTX } as any),
).rejects.toMatchObject({ code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', status: 500 });
expect(lastWrite(observed, 'sys_notification')).toBeUndefined();
},
);

it('a notification that CARRIES its organization is written as it came, on a walled posture too', async () => {
const { engine, observed } = await makeEngine({ posture: 'isolated', organizations: [ORG_ID, 'org_second'] });
await engine.insert('sys_notification', { topic: 'sweep.breach', organization_id: 'org_second' }, { context: SYSTEM_CTX } as any);
const write = lastWrite(observed, 'sys_notification');
expect(write?.method).toBe('create');
// Nothing was derived over the producer's answer: the engine resolved no
// organization of its own for a row that already names one.
expect(write?.options?.tenantId).toBeUndefined();
});
});

describe('#13491 gate 2 — the isSystem mute narrows by the SAME classification', () => {
it('an elevated write on an admitted platform object is no longer auto-muted', async () => {
const { engine, observed } = await makeEngine({ posture: 'single' });
Expand Down
22 changes: 22 additions & 0 deletions packages/objectql/src/tenancy/platform-object-tenancy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,28 @@ export const PLATFORM_OBJECT_TENANCY: Readonly<Record<string, PlatformObjectTena
'`recordOrgResolver.organizationOf(...)` and stamps it (`suspended-run-store.ts`); unattributed run ' +
'history was the measured symptom, and the same 2026-08-23 order covers its backfill.',
},
// #23284 (writer) + the 2026-08-24 #11303 ruling, verbatim:
// 「11303 sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」
// The table is NAMED by the ruling, and this card made every producer fill
// it: the `notify` node threads the run's organization, else the organization
// of the record the notification is about; the approvals service threads the
// request's. Admitting it is what hands a notification no producer could
// attribute to ADR-0131 D9 — the sole organization under `single`, a refusal
// where none is derivable — instead of the NULL row the HotCRM v18 pass
// measured on every system-context emit. Only the event row: its delivery
// was admitted already (below), and `emit()` threads the event row's stored
// organization onto the delivery, the inbox row and the receipt, so the
// family follows its notification on both the inline and the outbox path.
sys_notification: {
tenancy: 'tenant-scoped',
evidence:
'`MessagingService.writeEvent` (`messaging-service.ts`) stamps `organization_id` from the organization ' +
'its producer threads: the flow `notify` node passes the run\'s organization, else the organization of ' +
'the record the notification is about (`notify-node.ts`), and the approvals service passes the ' +
'request\'s (`approval-service.ts`). The maintainer ruled on 2026-08-24 that this table must carry ' +
'`organization_id`. An org-less row here is invisible to every report and cleanup that filters by ' +
'organization, so it is a defect, not a design.',
},
// #11698
sys_notification_delivery: {
tenancy: 'tenant-scoped',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
* 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`.
*
* ## And whose notification it is (#23284)
*
* The same battery pins the organization every call site hands messaging: the
* request's own `organization_id`, read back from the stored request row.
* Before it, `notify()` passed none, so every approval notification —
* `approval.requested` from a user's own submission included — reached
* `sys_notification` with `organization_id` NULL. The deals live in two
* organizations, so a site that passed one fixed organization, or the actor's,
* would not satisfy the pin.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down Expand Up @@ -63,6 +73,15 @@ 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.';

/**
* [#23284] The two organizations the battery's deals live in. The SLA pair sits
* in the second, every other request in the first — so "the request's
* organization" cannot be satisfied by a constant.
*/
const ORG_A = 'org_battery_a';
const ORG_B = 'org_battery_b';
const orgOfDeal = (key: string): string => (key.startsWith('sla_') ? ORG_B : ORG_A);

/** The fields `notify()` declares for a notification's payload. */
const PAYLOAD_FIELDS = new Set(['title', 'body', 'actionUrl', 'actions']);

Expand Down Expand Up @@ -113,6 +132,7 @@ interface Emitted {
audience: string[];
source?: { object: string; id: string };
payload?: Record<string, unknown>;
organizationId?: string;
}

describe('every approval notification carries its text in body', () => {
Expand All @@ -123,7 +143,7 @@ describe('every approval notification carries its text in body', () => {
const ids: Record<string, string> = {};

const open = async (key: string, approvers: any[], extra: Record<string, unknown> = {}) => {
await engine.insert('crm_deal', { id: `D_${key}`, title: key }, { context: SYSTEM } as any);
await engine.insert('crm_deal', { id: `D_${key}`, title: key, organization_id: orgOfDeal(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,
Expand Down Expand Up @@ -256,4 +276,21 @@ describe('every approval notification carries its text in body', () => {
}
expect(offenders).toEqual([]);
});

it("[#23284] every notification carries its request's organization, read back from the stored request", async () => {
const stored = new Map<string, string | null>();
for (const id of Object.values(ids)) {
const row = await engine.findOne('sys_approval_request', { where: { id }, fields: ['id', 'organization_id'] }, { context: SYSTEM });
stored.set(id, row?.organization_id ?? null);
}
// The fixture really is two organizations, and each request landed in its
// deal's — otherwise the identity below could hold for a constant.
expect(new Set(stored.values())).toEqual(new Set([ORG_A, ORG_B]));
for (const [key, id] of Object.entries(ids)) expect(stored.get(id), key).toBe(orgOfDeal(key));

const offenders = emits
.filter((e) => e.organizationId !== stored.get(e.source?.id ?? ''))
.map((e) => `${siteOf(e)}: organizationId ${JSON.stringify(e.organizationId)}, request ${JSON.stringify(stored.get(e.source?.id ?? ''))}`);
expect(offenders).toEqual([]);
});
});
Loading
Loading