Skip to content

Commit 4351e29

Browse files
committed
fix(service-messaging): the in-memory HTTP outbox's redeliver() no longer lets a tenant replay an organization-less row
MemoryHttpOutbox.redeliver() still carried the v17 NULL arm: a row with no organization passed every tenant's wall. ADR-0131 D8 rules a NULL arm out in every layer, and SqlHttpOutbox (through the driver's applyTenantScope) already answers RESOURCE_NOT_FOUND there. Match it, and flip the pin that held the old arm into one that refuses it. Claude-Session: https://claude.ai/code/session_017ae4eMXWeERGrG6heTbwBX Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5f47d9 commit 4351e29

2 files changed

Lines changed: 26 additions & 24 deletions

File tree

‎packages/services/service-messaging/src/http-outbox-organization.test.ts‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
* [#13546] `sys_http_delivery` rows carry the producer's organization.
55
*
66
* The consequence being pinned: the cross-organization wall on `redeliver()`
7-
* (#10740) scopes by the row's `organization_id`, and the driver's tenant term
8-
* is `(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
9-
* global-row fail-open. A row enqueued without an organization therefore
10-
* belongs to NO organization and is visible to EVERY one; before this repair
11-
* the enqueue door never stamped the column, so 100% of rows were in that arm
12-
* and the wall excluded nothing.
7+
* (#10740) scopes by the row's `organization_id`. The driver's tenant term was
8+
* then `(organization_id = :tenantId OR organization_id IS NULL)`, a global-row
9+
* fail-open, and the enqueue door never stamped the column, so 100% of rows
10+
* were in that arm and the wall excluded nothing. ADR-0131 D8 has since
11+
* retired the NULL arm: the term is `organization_id = :tenantId` alone, and
12+
* an organization-less row is reached by no tenant-scoped redeliver.
1313
*
1414
* ⭐ The control for this suite is the sibling notification outbox
1515
* (`SqlOutbox.enqueue` writes `organization_id: input.organizationId ?? null`)
@@ -166,16 +166,23 @@ describe('#13546 — MemoryHttpOutbox parity', () => {
166166
expect(row.organizationId).toBe('org_pin_alpha');
167167
});
168168

169-
it('an org-less row stays a GLOBAL row any tenant may replay (the driver fail-open arm, mirrored)', async () => {
170-
// The over-denial control: hiding NULL rows from every tenant would be
171-
// a different defect (platform rows invisible to everyone). The memory
172-
// predicate must mirror `(organization_id = :tenantId OR organization_id
173-
// IS NULL)`, not tighten it.
169+
it('an org-less row is INVISIBLE to every tenant-scoped redeliver — no NULL arm (ADR-0131 D8)', async () => {
170+
// The memory predicate mirrors the SQL driver's `organization_id =
171+
// :tenantId` with no `OR organization_id IS NULL` arm: NULL is not an
172+
// owner, so no tenant may replay a row of no organization.
174173
const outbox = new MemoryHttpOutbox();
175174
const id = await deadRow(outbox);
176175

177-
const row = await outbox.redeliver(id, { tenantId: 'org_pin_beta' });
178-
expect(row.status).toBe('pending');
176+
const attempt = outbox.redeliver(id, { tenantId: 'org_pin_beta' });
177+
await expect(attempt).rejects.toBeInstanceOf(HttpRedeliverError);
178+
await expect(attempt).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND' });
179+
const [row] = await outbox.list({ status: 'dead' });
180+
expect(row.id).toBe(id);
181+
expect(row.attempts).toBe(1);
182+
183+
// Still reachable where nothing scopes: a tenant-less caller.
184+
const replayed = await outbox.redeliver(id, { tenantId: undefined });
185+
expect(replayed.status).toBe('pending');
179186
});
180187

181188
it('a tenant-less caller stays unscoped (the honest degraded shape RedeliverOptions rules)', async () => {

‎packages/services/service-messaging/src/memory-http-outbox.ts‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -203,16 +203,13 @@ export class MemoryHttpOutbox implements IHttpOutbox {
203203
* owes the predicate here") — and `insert()` now stamps
204204
* {@link HttpDelivery.organizationId}, so the debt is due.
205205
*
206-
* The predicate was written to mirror the SQL driver's v17 tenant term
207-
* `(organization_id = :tenantId OR organization_id IS NULL)`, all three
208-
* arms deliberately:
206+
* The predicate mirrors the SQL driver's tenant term
207+
* `organization_id = :tenantId` (ADR-0131 D8 — no layer has a NULL arm):
209208
* - a row in ANOTHER organization is INVISIBLE (`RESOURCE_NOT_FOUND`),
210209
* never forbidden — the same non-oracle refusal the contract rules;
211-
* - a row with NO organization is reachable by every tenant here. ⚠️ The
212-
* SQL driver no longer does this: ADR-0131 D8 retired its NULL arm, so
213-
* there the term is `organization_id = :tenantId` alone and an
214-
* organization-less row is reached by no tenant. This in-memory store
215-
* still admits it;
210+
* - a row with NO organization is invisible to every tenant-scoped
211+
* caller too: NULL is not an owner (ADR-0131 D1), so no tenant's
212+
* redeliver reaches it — exactly as `SqlHttpOutbox` answers;
216213
* - a caller with NO tenant (`tenantId: undefined`) stays unscoped,
217214
* which is the honest degraded shape {@link RedeliverOptions} rules
218215
* for genuinely tenant-less deployments.
@@ -221,9 +218,7 @@ export class MemoryHttpOutbox implements IHttpOutbox {
221218
const row = this.rows.get(id);
222219
if (
223220
!row ||
224-
(options.tenantId !== undefined &&
225-
row.organizationId !== undefined &&
226-
row.organizationId !== options.tenantId)
221+
(options.tenantId !== undefined && row.organizationId !== options.tenantId)
227222
) {
228223
throw new HttpRedeliverError(`Delivery row '${id}' not found`, 'RESOURCE_NOT_FOUND');
229224
}

0 commit comments

Comments
 (0)