Skip to content

Commit 8163a1c

Browse files
authored
fix(messaging): classify the delivery dispatchers' updateMany sweeps as global environment sweeps (#10725)
* fix(messaging): classify the delivery dispatchers' updateMany sweeps as global (#10673) * chore: changeset for delivery dispatcher sweep classification (#10673)
1 parent 9185ff0 commit 8163a1c

5 files changed

Lines changed: 322 additions & 12 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/service-messaging": patch
3+
---
4+
5+
Classify the delivery dispatchers' predicate writes on `sys_http_delivery` and
6+
`sys_notification_delivery` as global environment sweeps (#10673). On a walled
7+
deployment (`OS_TENANCY_POSTURE=isolated|group`) the SQL driver's tenant-audit
8+
gate reported every `updateMany` these outboxes issue from the claim path as an
9+
un-isolated write. The audit was right to ask: both objects are tenant-scoped
10+
via `organization_id`. The answer is that these six writes — the
11+
visibility-timeout reap and the atomic claim in `SqlHttpOutbox.claim`,
12+
`SqlNotificationOutbox.claim` and `SqlNotificationOutbox.claimDigest` — are
13+
issued by a `setInterval` dispatcher tick under a cluster lock, with no request
14+
context and no tenant anywhere in the `ClaimOptions` contract, and they must
15+
cross organizations: one outbox drains the whole environment's queue, so a
16+
per-organization predicate would strand every other organization's deliveries.
17+
They now pass `bypassTenantAudit` through a single documented helper that
18+
carries that warrant. Diagnostics only — per its spec the flag never changes
19+
what a write touches, and the row-level `ack` / `redeliver` writes are
20+
unaffected.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #10673 — the delivery dispatchers' predicate writes are classified, not
5+
* silenced.
6+
*
7+
* ## What was measured
8+
* On a walled deployment (`OS_TENANCY_POSTURE=isolated`) the SQL driver's
9+
* `auditMissingTenant` gate printed, for both delivery objects:
10+
*
11+
* [tenant-audit] updateMany on tenant-scoped object "sys_http_delivery"
12+
* without options.tenantId — writes will not be tenant-isolated.
13+
* [tenant-audit] updateMany on tenant-scoped object "sys_notification_delivery"
14+
* without options.tenantId — writes will not be tenant-isolated.
15+
*
16+
* The audit is right that the writes are environment-wide. The fix is the
17+
* classification it demands, not the quiet: each `multi: true` write on the
18+
* claim path is declared a global dispatcher sweep (`bypassTenantAudit`), with
19+
* the warrant in `outbox-dispatcher-scope.ts`. See that file for why a
20+
* `tenantId` is not merely absent but unavailable and unwanted here.
21+
*
22+
* ## Why this harness rather than the composed boot
23+
* The card's repro is an EE image booted under docker compose, which this
24+
* checkout cannot run. What stands in its place is the instrument's OWN
25+
* criterion, exercised end to end: a real `SqlDriver` on better-sqlite3, real
26+
* `syncSchemas()` (so `organization_id` is really provisioned and
27+
* `resolveTenantField` really answers), the real `OS_TENANCY_POSTURE` read,
28+
* the production `SqlHttpOutbox` / `SqlNotificationOutbox`, and the driver's
29+
* own logger as the assertion surface — the same substitution
30+
* `sql-driver-tenant-audit-posture.test.ts` makes.
31+
*
32+
* ## The vacuity traps closed here, explicitly
33+
* 1. **A green that means "the audit was never armed".** Every test that
34+
* asserts silence first asserts the gate's own preconditions are live —
35+
* `resolveTenantField(object) === 'organization_id'` — and then performs a
36+
* deliberately unscoped `multi: true` write on the SAME object through the
37+
* SAME driver and requires the warning to appear. Without that positive
38+
* control an object that stopped being tenant-scoped, a posture that
39+
* stopped resolving, or a typo in the matcher would all read as a fix.
40+
* 2. **A "fix" that touches nothing.** Silence is cheap for an implementation
41+
* that claims no rows. Every claim assertion pins the ROWS: both
42+
* organizations' rows move, and their `organization_id` survives the write
43+
* — the cross-organization reach is the operation's semantics, so a
44+
* regression to per-organization scoping must go red here.
45+
*/
46+
47+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
48+
import { ObjectQL } from '@objectstack/objectql';
49+
import { SqlDriver } from '@objectstack/driver-sql';
50+
import { SqlHttpOutbox } from './sql-http-outbox.js';
51+
import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js';
52+
import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js';
53+
import { NotificationDelivery } from './objects/notification-delivery.object.js';
54+
55+
const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
56+
const OLD_AUDIT = process.env.OS_TENANT_AUDIT;
57+
58+
let engine: ObjectQL;
59+
let driver: SqlDriver;
60+
let warns: Array<{ msg: string; meta: any }>;
61+
62+
/** The audit line the card quotes, matched on object + op. */
63+
const auditedUpdateMany = (object: string): boolean =>
64+
warns.some((w) => w.msg.includes(`[tenant-audit] updateMany on tenant-scoped object "${object}"`));
65+
66+
beforeEach(async () => {
67+
// The posture is read LIVE by `isMultiTenantMode()` (#5262), so setting it
68+
// here really does arm the gate for the writes below.
69+
process.env.OS_TENANCY_POSTURE = 'isolated';
70+
delete process.env.OS_TENANT_AUDIT;
71+
72+
driver = new SqlDriver({
73+
client: 'better-sqlite3',
74+
connection: { filename: ':memory:' },
75+
useNullAsDefault: true,
76+
});
77+
warns = [];
78+
(driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) };
79+
80+
engine = new ObjectQL();
81+
engine.registerDriver(driver, true);
82+
await engine.init();
83+
engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging');
84+
engine.registry.registerObject(NotificationDelivery as any, '@objectstack/service-messaging');
85+
await engine.syncSchemas();
86+
});
87+
88+
afterEach(async () => {
89+
try { await engine?.destroy(); } catch { /* noop */ }
90+
if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
91+
else process.env.OS_TENANCY_POSTURE = OLD_POSTURE;
92+
if (OLD_AUDIT === undefined) delete process.env.OS_TENANT_AUDIT;
93+
else process.env.OS_TENANT_AUDIT = OLD_AUDIT;
94+
});
95+
96+
/**
97+
* The positive control. An unscoped predicate write on `object`, issued
98+
* directly through the engine with no `bypassTenantAudit`, MUST produce the
99+
* audit line — otherwise a silent run proves nothing about the code under
100+
* test. Deliberately run AFTER the assertion it guards: the gate throttles one
101+
* warning per `${object}:${op}`, so this only fires if the production path
102+
* consumed no `updateMany` warning of its own.
103+
*/
104+
async function controlUnscopedUpdateMany(object: string): Promise<void> {
105+
// A PREDICATE write (no `id`), so it routes through `driver.updateMany`
106+
// exactly as the production path does; matching zero rows is fine — the
107+
// audit fires before the statement runs.
108+
await engine.update(object, { attempts: 99 }, { where: { status: '__control_no_such_status__' }, multi: true } as any);
109+
expect(
110+
auditedUpdateMany(object),
111+
`positive control failed: an unscoped multi:true write on ${object} produced no [tenant-audit] `
112+
+ 'line, so this file cannot distinguish "classified" from "audit not armed"',
113+
).toBe(true);
114+
}
115+
116+
async function seedHttpRow(id: string, org: string, over: Record<string, unknown> = {}): Promise<void> {
117+
const now = new Date();
118+
await engine.insert(SYS_HTTP_DELIVERY, {
119+
id,
120+
source: 'test',
121+
ref_id: id,
122+
dedup_key: id,
123+
url: 'https://receiver.example/hook',
124+
method: 'POST',
125+
payload_json: '{}',
126+
partition_key: 0,
127+
status: 'pending',
128+
attempts: 0,
129+
organization_id: org,
130+
created_at: now,
131+
updated_at: now,
132+
...over,
133+
} as any);
134+
}
135+
136+
// ───────────────────────────────────────────────────────────────────────────
137+
describe('sys_http_delivery — the dispatcher claim path is a classified global sweep', () => {
138+
it('claims across organizations without a tenant-audit finding', async () => {
139+
// The gate's own precondition: this object really is tenant-scoped.
140+
expect((driver as any).resolveTenantField(SYS_HTTP_DELIVERY)).toBe('organization_id');
141+
142+
await seedHttpRow('h_a', 'org_a');
143+
await seedHttpRow('h_b', 'org_b');
144+
145+
const outbox = new SqlHttpOutbox(engine as any, { partitionCount: 1 });
146+
const claimed = await outbox.claim({ nodeId: 'node1', limit: 10, claimTtlMs: 60_000 });
147+
148+
// ① the classified write is silent…
149+
expect(auditedUpdateMany(SYS_HTTP_DELIVERY)).toBe(false);
150+
// ② …and it still reaches every organization, which is the point.
151+
expect(claimed.map((c) => c.id).sort()).toEqual(['h_a', 'h_b']);
152+
const rows = (await engine.find(SYS_HTTP_DELIVERY, { where: {} })) as any[];
153+
expect(rows.map((r) => `${r.id}:${r.organization_id}:${r.status}`).sort()).toEqual([
154+
'h_a:org_a:in_flight',
155+
'h_b:org_b:in_flight',
156+
]);
157+
158+
await controlUnscopedUpdateMany(SYS_HTTP_DELIVERY);
159+
});
160+
161+
it('reaps a crashed node\'s in_flight rows in every organization, without a finding', async () => {
162+
const stale = Date.now() - 10 * 60_000;
163+
await seedHttpRow('h_a', 'org_a', { status: 'in_flight', claimed_by: 'dead_node', claimed_at: stale });
164+
await seedHttpRow('h_b', 'org_b', { status: 'in_flight', claimed_by: 'dead_node', claimed_at: stale });
165+
166+
const outbox = new SqlHttpOutbox(engine as any, { partitionCount: 1 });
167+
const claimed = await outbox.claim({ nodeId: 'node2', limit: 10, claimTtlMs: 60_000 });
168+
169+
expect(auditedUpdateMany(SYS_HTTP_DELIVERY)).toBe(false);
170+
// Both organizations' abandoned rows were recovered AND re-claimed by
171+
// the live node — a per-organization reap would have stranded one.
172+
expect(claimed.map((c) => c.id).sort()).toEqual(['h_a', 'h_b']);
173+
const rows = (await engine.find(SYS_HTTP_DELIVERY, { where: {} })) as any[];
174+
expect(rows.every((r) => r.claimed_by === 'node2')).toBe(true);
175+
176+
await controlUnscopedUpdateMany(SYS_HTTP_DELIVERY);
177+
});
178+
});
179+
180+
// ───────────────────────────────────────────────────────────────────────────
181+
describe('sys_notification_delivery — the dispatcher claim path is a classified global sweep', () => {
182+
it('claims across organizations without a tenant-audit finding', async () => {
183+
expect((driver as any).resolveTenantField(DELIVERY_OBJECT)).toBe('organization_id');
184+
185+
const outbox = new SqlNotificationOutbox(engine as any, { partitionCount: 1 });
186+
const idA = await outbox.enqueue({
187+
notificationId: 'n_a', recipientId: 'u_a', channel: 'inbox', organizationId: 'org_a', payload: {},
188+
} as any);
189+
const idB = await outbox.enqueue({
190+
notificationId: 'n_b', recipientId: 'u_b', channel: 'inbox', organizationId: 'org_b', payload: {},
191+
} as any);
192+
193+
const claimed = await outbox.claim({ nodeId: 'node1', limit: 10, claimTtlMs: 60_000 });
194+
195+
expect(auditedUpdateMany(DELIVERY_OBJECT)).toBe(false);
196+
expect(claimed.map((c) => c.id).sort()).toEqual([idA, idB].sort());
197+
// The organization stamped at enqueue survives the sweep untouched —
198+
// the sweep moves `status`, never a row's tenant.
199+
expect(claimed.map((c) => c.organizationId).sort()).toEqual(['org_a', 'org_b']);
200+
201+
await controlUnscopedUpdateMany(DELIVERY_OBJECT);
202+
});
203+
204+
it('collapses a digest window across organizations without a finding', async () => {
205+
const outbox = new SqlNotificationOutbox(engine as any, { partitionCount: 1 });
206+
await outbox.enqueue({
207+
notificationId: 'n_a', recipientId: 'u_a', channel: 'inbox', organizationId: 'org_a',
208+
payload: {}, digestKey: 'u_a|inbox|w1',
209+
} as any);
210+
await outbox.enqueue({
211+
notificationId: 'n_b', recipientId: 'u_b', channel: 'inbox', organizationId: 'org_b',
212+
payload: {}, digestKey: 'u_b|inbox|w1',
213+
} as any);
214+
215+
const claimed = await outbox.claimDigest({ nodeId: 'node1', limit: 10, claimTtlMs: 60_000 });
216+
217+
expect(auditedUpdateMany(DELIVERY_OBJECT)).toBe(false);
218+
expect(claimed.map((c) => c.organizationId).sort()).toEqual(['org_a', 'org_b']);
219+
220+
await controlUnscopedUpdateMany(DELIVERY_OBJECT);
221+
});
222+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { EngineUpdateOptions } from '@objectstack/spec/data';
4+
5+
/**
6+
* The write options for a delivery-outbox **dispatcher sweep** — every
7+
* predicate write (`multi: true`, i.e. `driver.updateMany`) that
8+
* {@link SqlNotificationOutbox} and {@link SqlHttpOutbox} issue against
9+
* `sys_notification_delivery` / `sys_http_delivery` from the claim path.
10+
*
11+
* ## Why these writes carry `bypassTenantAudit` instead of a `tenantId`
12+
*
13+
* Both objects are tenant-scoped: the kernel provisions `organization_id` on
14+
* them, so `SqlDriver.resolveTenantField()` answers `organization_id` and the
15+
* driver's `auditMissingTenant` gate treats every unscoped write to them as a
16+
* finding on a walled deployment (`OS_TENANCY_POSTURE=isolated|group`). That
17+
* gate is right to ask, and the two legal answers are "thread the caller's
18+
* tenant" or "declare this write global, and say why". These sweeps are the
19+
* second, and the warrant is structural rather than aesthetic:
20+
*
21+
* 1. **No request context exists to thread.** The only callers are
22+
* `NotificationDispatcher` and `HttpDispatcher`, whose `runPartition()`
23+
* runs off a `setInterval` tick under a cluster lock keyed
24+
* `notify.dispatcher.partition.<n>` / `http.dispatcher.partition.<n>`.
25+
* There is no HTTP request, no session and no active organization on that
26+
* path — the tick is a platform actor, not a tenant's.
27+
* 2. **The outbox contract has no tenant to thread even in principle.**
28+
* `ClaimOptions` / `HttpClaimOptions` are `{ nodeId, limit, partition,
29+
* claimTtlMs, now }`. Partitioning is `hash(refId | notificationId |
30+
* digestKey) mod N` — a load-spreading key, deliberately *not* an
31+
* organization key — so a partition holds rows from every organization by
32+
* construction.
33+
* 3. **Scoping them would break delivery, not isolate it.** One outbox and
34+
* one dispatcher pair are constructed per ENVIRONMENT
35+
* (`messaging-service-plugin.ts`), and they drain the whole environment's
36+
* queue. An `organization_id = <one org>` predicate on the claim would
37+
* strand every other organization's pending notifications and callouts
38+
* forever, and one on the visibility-timeout reap would leave rows a
39+
* crashed node abandoned for other organizations permanently `in_flight`.
40+
* Crossing organizations is the operation's *semantics*, not an oversight.
41+
*
42+
* ⚠️ This is a **diagnostics** flag and nothing else: per its spec
43+
* (`DriverOptionsSchema.bypassTenantAudit`) it "never changes what the write
44+
* touches". It silences a warning about a write that was already, and
45+
* correctly, environment-wide. It must never be reached for to quiet a write
46+
* that a request context could have scoped — that is the failure mode the
47+
* audit exists to prevent, and the row-level writes on these same objects
48+
* (`ack`, `redeliver`) are single-record `multi: false` writes that do **not**
49+
* use this helper.
50+
*
51+
* @param where Predicate identifying the rows this sweep claims or reaps.
52+
*/
53+
export function dispatcherSweepOptions(
54+
where: Record<string, unknown>,
55+
): EngineUpdateOptions & { multi: true; bypassTenantAudit: true } {
56+
return { where, multi: true, bypassTenantAudit: true };
57+
}

packages/services/service-messaging/src/sql-http-outbox.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
44
import type { IDataEngine } from '@objectstack/spec/contracts';
55
import { hashPartition } from './backoff.js';
66
import { toEpochMs } from './audit-timestamp.js';
7+
import { dispatcherSweepOptions } from './outbox-dispatcher-scope.js';
78
import { deliveryBody, signBody } from './http-sender.js';
89
import {
910
HttpRedeliverError,
@@ -193,13 +194,12 @@ export class SqlHttpOutbox implements IHttpOutbox {
193194
await this.engine.update(
194195
this.objectName,
195196
{ status: 'pending', claimed_by: null, claimed_at: null },
196-
{
197-
where: {
198-
status: 'in_flight',
199-
claimed_at: { $lt: now - opts.claimTtlMs },
200-
},
201-
multi: true,
202-
},
197+
// Environment-wide by design: recovers rows a crashed node abandoned,
198+
// for every organization. Warrant in `outbox-dispatcher-scope.ts`.
199+
dispatcherSweepOptions({
200+
status: 'in_flight',
201+
claimed_at: { $lt: now - opts.claimTtlMs },
202+
}),
203203
);
204204

205205
// 2. Pick candidate ids.
@@ -221,7 +221,9 @@ export class SqlHttpOutbox implements IHttpOutbox {
221221
await this.engine.update(
222222
this.objectName,
223223
{ status: 'in_flight', claimed_by: opts.nodeId, claimed_at: now },
224-
{ where: { id: { $in: ids }, status: 'pending' }, multi: true },
224+
// Environment-wide by design: the dispatcher drains every
225+
// organization's queue. Warrant in `outbox-dispatcher-scope.ts`.
226+
dispatcherSweepOptions({ id: { $in: ids }, status: 'pending' }),
225227
);
226228

227229
// 4. Read back the rows we actually own.

0 commit comments

Comments
 (0)