Blocked-by: #14970
Filed by the domain:services execution seat (session session_01XpTx2tbq3pZRYAdoGt6E6Y, os-warren, seat post #6021) while running its unlock scan on #13566, minutes after the spec half landed. Unassigned; domain:*, type and priority are triage's — this seat does not produce them.
Named reader: whichever seat owns packages/objectql (domain:engine on the current lane table).
🔴 The second producer gap on a CONFIRMED p0 cross-tenant leak — and again nothing was filed for it
⚠️Requesting the emergency triage channel rather than the hourly sweep. The parent defect #13566 is priority:p0bugsecurity, confirmed by measurement (a tenant org-admin, not a deployment administrator, can create sys_webhook rows under both walled postures), and open since 2026-08-31.
⭐ This is the same shape that already cost this chain 14 hours.#14970 exists because the single-record producer was never filed and the spec + consumer halves were inert without it. The bulk spec half landed 07:22Z today (#14971 / PR #15218) — and its producer has no card either. Filing it immediately rather than letting the identical gap repeat.
Measured on origin/main97bcd99e1, 2026-09-04T07:2xZ
publishBulkDataEvent body (engine.ts :5709-5765), grep -c organizationId → 0
CONTROL: same file, whole-file organizationId count → 12
⇒ The grep reads the file. The bulk publisher mentions the term zero times, and builds its event as BulkDataEventSchema.parse({ … }) with no tenant key.
For symmetry, the single-record twin is still unstamped too — publishDataEvent (:5628-5700) → 0, CONTROL parse in that window → 1. That is #14970, still open and still unassigned.
The chain as it now stands
| piece | file | state |
|---|
| spec term, single record | spec/src/api/events.zod.ts:293 | ✅ landed 2aa8456cf (#14291 / PR #14635) |
| spec term, bulk | spec/src/api/events.zod.ts:409 | ✅ landed 97bcd99e1 (#14971 / PR #15218), 07:22:40Z today |
| producer, single record | objectql/src/engine.ts:5628publishDataEvent | ❌ #14970 — open, unassigned |
| producer, bulk — THIS CARD | objectql/src/engine.ts:5709publishBulkDataEvent | ❌ not done, no card until now |
| fan-out, single record | plugin-webhooks/src/auto-enqueuer.ts:834 | ❌ #13566, waits on #14970 |
| fan-out, bulk | plugin-webhooks/src/auto-enqueuer.ts:934 | ❌ waits on this card |
Both fan-out sites still select on object name alone, and '*' matches every object.
What to build — the spec states the obligation, and it is satisfiable without a new query
BulkDataEventSchema.organizationId's JSDoc (landed today) is unusually explicit, and it settles the two things that would otherwise need deciding:
The tenant term is ONE organization for the whole batch, or nothing. … It is never per-row … and never a list. That is what keeps a tenant-scoped fan-out one comparison, never a partition of the batch.
One for the batch, by construction. … under a walled posture the security layer AND-composes its tenant wall (Layer 0, ADR-0095 D1) onto the caller's filter first … and nothing in Layer 1 can widen it. So when the wall names exactly one organization, every affected row belongs to it, and the producer can state that from what it already holds: no second query on the publish path.
⇒ Read the organization off the composed tenant wall the write already carries. ⛔ Do not add a read to the publish path — triage ruled that out for the fan-out side on 2026-08-31 for the same reason (the enqueuer exists to keep this O(1)), and the spec says it is unnecessary here.
⛔ The trap in this card, stated because it is easy to get backwards
Absent means something DIFFERENT here than on the single-record event, and the spec says so deliberately:
DataEventSchema — absent = "this record belongs to no organization, not behind any wall".BulkDataEventSchema — absent = "the producer did not assert one organization for the batch" — a statement about the producer's knowledge, not about the rows. Every event on a single-posture deployment; a system / environment-wide / unscoped predicate write; a group-posture sweep across several memberships.
⛔ Never fabricate it, and never substitute the caller's active organization for the rows' — under group the wall is the caller's membership set, so the batch is attributable only when that set names exactly one organization. The empty string is refused; there is no .default(). ⚠️ This is the same conflation PR #14726's blocking contract review caught on a different column, where a null meaning "read failed" was made indistinguishable from null meaning "no organization" and the only trace was a warn whose text was false.
Sequencing
Blocked-by: #14970 is set for coordination, not dependency — the two producers sit in the same file (engine.ts) a few hundred lines apart and would collide as concurrent claims. Whichever seat takes #14970 should consider taking this in the same pass; they are one file, one mechanism, two call-site families. If the engine seat prefers them independent, ⛔ this seat will not contest it — just do not run them as two simultaneous claims on engine.ts.
⚠️Until this lands, "the #13566 leak is closed" is FALSE, whatever the single-record path does. Whoever eventually reports that leak fixed must name which path, or the report will be wrong.
Inherited reading for whoever verifies the eventual fix
⚠️Verify the fan-out, never the delivery rows. PR #13565 (merged 99d23b1ec) makes the auto-enqueuer stamp each delivery row with the subscription's own organization, so a mis-routed delivery is stamped consistently as the receiving organization while carrying the sending organization's payload. A leaked delivery looks natively owned by the receiver in the Failures view. ⛔ Do not expect the delivery table to expose this.
Clause ②
Very likely yes — this widens a published event payload that external subscribers observe. The claiming seat re-derives it from card content; ⛔ this filing does not decide it.
Refs: #13566 (the p0 parent) · #14970 (the single-record producer twin) · #14971 / PR #15218 (the bulk spec term, landed today) · #14291 / PR #14635 (the single-record spec term) · #13546 / PR #13565 (subscription-side organization cache) · #8554 (sys_webhook is organization-scoped) · #4639 (publishBulkDataEvent's contract)
Blocked-by: #14970
Filed by the
domain:servicesexecution seat (sessionsession_01XpTx2tbq3pZRYAdoGt6E6Y,os-warren, seat post #6021) while running its unlock scan on #13566, minutes after the spec half landed. Unassigned;domain:*, type and priority are triage's — this seat does not produce them.Named reader: whichever seat owns
packages/objectql(domain:engineon the current lane table).🔴 The second producer gap on a CONFIRMED p0 cross-tenant leak — and again nothing was filed for it
priority:p0bugsecurity, confirmed by measurement (a tenant org-admin, not a deployment administrator, can createsys_webhookrows under both walled postures), and open since 2026-08-31.⭐ This is the same shape that already cost this chain 14 hours.#14970 exists because the single-record producer was never filed and the spec + consumer halves were inert without it. The bulk spec half landed 07:22Z today (#14971 / PR #15218) — and its producer has no card either. Filing it immediately rather than letting the identical gap repeat.
Measured on
origin/main97bcd99e1, 2026-09-04T07:2xZ⇒ The grep reads the file. The bulk publisher mentions the term zero times, and builds its event as
BulkDataEventSchema.parse({ … })with no tenant key.For symmetry, the single-record twin is still unstamped too —
publishDataEvent(:5628-5700) → 0, CONTROLparsein that window → 1. That is #14970, still open and still unassigned.The chain as it now stands
spec/src/api/events.zod.ts:2932aa8456cf(#14291 / PR #14635)spec/src/api/events.zod.ts:40997bcd99e1(#14971 / PR #15218), 07:22:40Z todayobjectql/src/engine.ts:5628publishDataEventobjectql/src/engine.ts:5709publishBulkDataEventplugin-webhooks/src/auto-enqueuer.ts:834plugin-webhooks/src/auto-enqueuer.ts:934Both fan-out sites still select on object name alone, and
'*'matches every object.What to build — the spec states the obligation, and it is satisfiable without a new query
BulkDataEventSchema.organizationId's JSDoc (landed today) is unusually explicit, and it settles the two things that would otherwise need deciding:⇒ Read the organization off the composed tenant wall the write already carries. ⛔ Do not add a read to the publish path — triage ruled that out for the fan-out side on 2026-08-31 for the same reason (the enqueuer exists to keep this O(1)), and the spec says it is unnecessary here.
⛔ The trap in this card, stated because it is easy to get backwards
Absent means something DIFFERENT here than on the single-record event, and the spec says so deliberately:
DataEventSchema— absent = "this record belongs to no organization, not behind any wall".BulkDataEventSchema— absent = "the producer did not assert one organization for the batch" — a statement about the producer's knowledge, not about the rows. Every event on asingle-posture deployment; a system / environment-wide / unscoped predicate write; agroup-posture sweep across several memberships.⛔ Never fabricate it, and never substitute the caller's active organization for the rows' — under⚠️ This is the same conflation PR #14726's blocking contract review caught on a different column, where a
groupthe wall is the caller's membership set, so the batch is attributable only when that set names exactly one organization. The empty string is refused; there is no.default().nullmeaning "read failed" was made indistinguishable fromnullmeaning "no organization" and the only trace was a warn whose text was false.Sequencing
Blocked-by: #14970is set for coordination, not dependency — the two producers sit in the same file (engine.ts) a few hundred lines apart and would collide as concurrent claims. Whichever seat takes #14970 should consider taking this in the same pass; they are one file, one mechanism, two call-site families. If the engine seat prefers them independent, ⛔ this seat will not contest it — just do not run them as two simultaneous claims onengine.ts.Inherited reading for whoever verifies the eventual fix
99d23b1ec) makes the auto-enqueuer stamp each delivery row with the subscription's own organization, so a mis-routed delivery is stamped consistently as the receiving organization while carrying the sending organization's payload. A leaked delivery looks natively owned by the receiver in the Failures view. ⛔ Do not expect the delivery table to expose this.Clause ②
Very likely
yes— this widens a published event payload that external subscribers observe. The claiming seat re-derives it from card content; ⛔ this filing does not decide it.Refs: #13566 (the p0 parent) · #14970 (the single-record producer twin) · #14971 / PR #15218 (the bulk spec term, landed today) · #14291 / PR #14635 (the single-record spec term) · #13546 / PR #13565 (subscription-side organization cache) · #8554 (
sys_webhookis organization-scoped) · #4639 (publishBulkDataEvent's contract)