Consequence first
sys_http_delivery is tenant-scoped, and the platform built a deliberate cross-organization wall on it. WebhookOutboxPlugin.registerAdminRoutes says so in as many words (packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:364):
sys_http_delivery is tenant-scoped, and this is the one door on it a request can reach: an unscoped replay from here is an authenticated user reaching another organization's delivery row on a walled deployment. With the tenant threaded, a row outside the caller's organization is simply not found.
That wall is reachable around for 100% of rows, because 100% of rows are written with organization_id = NULL.
The driver's tenant term is (organization_id = :tenantId OR organization_id IS NULL) — SqlDriver.applyTenantScope, packages/drivers/driver-sql/src/sql-driver.ts:11931. The OR ... IS NULL arm is a deliberate global-row fail-open so platform rows are not hidden from every tenant. A NULL-tenant row therefore belongs to no organization and is visible to every one. Since the enqueue door never stamps the column, every row of this object is in that arm, and the scoping repair on redeliver() — 200 lines up the same file — can never exclude anything.
The gap
SqlHttpOutbox.insert (packages/services/service-messaging/src/sql-http-outbox.ts:178, reached from enqueue() and recordUndeliverable()) builds its row and calls engine.insert(this.objectName, row):
- no execution context, so
ObjectQLEngine.buildDriverOptions produces no DriverOptions.tenantId;
- no
organization_id on the row, so SqlDriver.injectTenantOnInsert (sql-driver.ts:11944) has nothing to stamp and returns at its first guard;
- and the object is in the platform namespace, so the engine's own
resolveSystemInsertOrganization derivation returns undefined at packages/objectql/src/engine.ts:3765 (if (isPlatformNamespaceObject(object)) return undefined;) rather than deriving or refusing.
The gap is in the CONTRACT, not only the implementation. EnqueueHttpInput has no organizationId member at all, so no caller can supply one even when it has one to give. Measured by content on 50cf2940b9, with the sibling outbox as the control — the notification outbox received exactly this repair and the HTTP one did not:
| file |
role |
occurrences of organizationId |
packages/services/service-messaging/src/http-outbox.ts |
HTTP outbox interface |
0 |
packages/services/service-messaging/src/sql-http-outbox.ts |
HTTP outbox implementation |
0 |
packages/services/service-messaging/src/outbox.ts |
notification outbox interface |
2 |
packages/services/service-messaging/src/sql-outbox.ts |
notification outbox implementation |
2 |
SqlOutbox.enqueue (sql-outbox.ts:121) writes organization_id: input.organizationId ?? null onto its row. SqlHttpOutbox.insert has no equivalent line and no field to read it from. Both files exist and both were read, so the zero is a measurement rather than a missing file.
Producers, and why an HTTP middleware does not close this
The two producers of sys_http_delivery rows are:
packages/services/service-automation/src/builtin/http-nodes.ts:125 — an automation flow node;
- the webhook auto-enqueuer, wired at
packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:302, which the same file's own diagram (:86) describes as running fire-and-forget, off the write path.
So even for an HTTP-originated write the row is inserted after the request has moved on, from a payload the service constructs itself. There are exactly four seams by which a tenant value can reach a write in this platform — ExecutionContext.tenantId, a DriverOptions.tenantId in the options bag, the tenant column set on the row, and the engine's own insert-side derivation — and all four are explicit at the call site. The engine's only AsyncLocalStorage (engine.ts:2225) carries transaction and scope, not a tenant, so there is no ambient slot an outer layer could fill.
Suggested shape, not a prescription
The narrowest repair that matches what the sibling already does: give EnqueueHttpInput an organizationId member, have the two producers pass the organization they are acting for, and stamp it on the row in SqlHttpOutbox.insert the way SqlOutbox.enqueue does. Whether existing NULL rows are backfilled is a separate decision with precedent on both sides in this area.
⚠️ Line numbers are as of 50cf2940b9 and rot. Re-locate by symbol — SqlHttpOutbox.insert, applyTenantScope, injectTenantOnInsert, resolveSystemInsertOrganization, registerAdminRoutes — rather than by line.
Measured while executing the stamper-gap measurement on #13497; recorded there as well, and filed separately so it does not live only in a comment. Not graded here: no priority or domain label, unassigned, for triage.
Generated by Claude Code
Generated by Claude Code
Consequence first
sys_http_deliveryis tenant-scoped, and the platform built a deliberate cross-organization wall on it.WebhookOutboxPlugin.registerAdminRoutessays so in as many words (packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:364):That wall is reachable around for 100% of rows, because 100% of rows are written with
organization_id = NULL.The driver's tenant term is
(organization_id = :tenantId OR organization_id IS NULL)—SqlDriver.applyTenantScope,packages/drivers/driver-sql/src/sql-driver.ts:11931. TheOR ... IS NULLarm is a deliberate global-row fail-open so platform rows are not hidden from every tenant. A NULL-tenant row therefore belongs to no organization and is visible to every one. Since the enqueue door never stamps the column, every row of this object is in that arm, and the scoping repair onredeliver()— 200 lines up the same file — can never exclude anything.The gap
SqlHttpOutbox.insert(packages/services/service-messaging/src/sql-http-outbox.ts:178, reached fromenqueue()andrecordUndeliverable()) builds its row and callsengine.insert(this.objectName, row):ObjectQLEngine.buildDriverOptionsproduces noDriverOptions.tenantId;organization_idon the row, soSqlDriver.injectTenantOnInsert(sql-driver.ts:11944) has nothing to stamp and returns at its first guard;resolveSystemInsertOrganizationderivation returnsundefinedatpackages/objectql/src/engine.ts:3765(if (isPlatformNamespaceObject(object)) return undefined;) rather than deriving or refusing.The gap is in the CONTRACT, not only the implementation.
EnqueueHttpInputhas noorganizationIdmember at all, so no caller can supply one even when it has one to give. Measured by content on50cf2940b9, with the sibling outbox as the control — the notification outbox received exactly this repair and the HTTP one did not:organizationIdpackages/services/service-messaging/src/http-outbox.tspackages/services/service-messaging/src/sql-http-outbox.tspackages/services/service-messaging/src/outbox.tspackages/services/service-messaging/src/sql-outbox.tsSqlOutbox.enqueue(sql-outbox.ts:121) writesorganization_id: input.organizationId ?? nullonto its row.SqlHttpOutbox.inserthas no equivalent line and no field to read it from. Both files exist and both were read, so the zero is a measurement rather than a missing file.Producers, and why an HTTP middleware does not close this
The two producers of
sys_http_deliveryrows are:packages/services/service-automation/src/builtin/http-nodes.ts:125— an automation flow node;packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:302, which the same file's own diagram (:86) describes as running fire-and-forget, off the write path.So even for an HTTP-originated write the row is inserted after the request has moved on, from a payload the service constructs itself. There are exactly four seams by which a tenant value can reach a write in this platform —
ExecutionContext.tenantId, aDriverOptions.tenantIdin the options bag, the tenant column set on the row, and the engine's own insert-side derivation — and all four are explicit at the call site. The engine's onlyAsyncLocalStorage(engine.ts:2225) carriestransactionandscope, not a tenant, so there is no ambient slot an outer layer could fill.Suggested shape, not a prescription
The narrowest repair that matches what the sibling already does: give
EnqueueHttpInputanorganizationIdmember, have the two producers pass the organization they are acting for, and stamp it on the row inSqlHttpOutbox.insertthe waySqlOutbox.enqueuedoes. Whether existing NULL rows are backfilled is a separate decision with precedent on both sides in this area.50cf2940b9and rot. Re-locate by symbol —SqlHttpOutbox.insert,applyTenantScope,injectTenantOnInsert,resolveSystemInsertOrganization,registerAdminRoutes— rather than by line.Measured while executing the stamper-gap measurement on #13497; recorded there as well, and filed separately so it does not live only in a comment. Not graded here: no priority or domain label, unassigned, for triage.
Generated by Claude Code
Generated by Claude Code