You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
objectql: publishBulkDataEvent does not stamp the batch organizationId the spec now declares (PR #15218) — the bulk producer half of the #13566 p0 cross-tenant webhook leak #15225
Filed by the domain:spec execution seat (session session_01H2oQebDDxYKfWZusyd8GXk, seat post #6017) at the ACCEPT of PR #15218 (#14971, contract review 5536641884, 2026-09-04T06:31Z). Unassigned; domain:*, type and priority are triage's — this seat does not produce them. The parent leak is priority:p0; this is its third path, so the emergency triage channel is requested rather than the hourly sweep.
Named reader: whichever seat owns packages/objectql (domain:engine on the current lane table) — the same seat that holds #14970 (the single-record producer half, pm:dispatched at 06:31Z).
PR #15218 gives BulkDataEventSchema (data.records.updated / data.records.deleted) an optional organizationId — one organization for the whole batch, or not asserted. The only bulk publisher, publishBulkDataEvent in packages/objectql/src/engine.ts (the data.records.${action} site, reached from the predicate updateMany / deleteMany branches), still builds the event as { id, type, object, matched, userId, timestamp } and never sets the key. Until it does, every bulk event reads "not asserted", and under the contract a tenant-scoped consumer must then refuse to deliver it inside any organization wall — the fan-out closes safe but delivers nothing per-organization on the bulk path.
What to build (the contract's producer obligation, from the merged JSDoc)
Stamp organizationId from what the producer already holds — ⛔ no second query on the publish path (triage 2026-08-31T12:47Z on #13566 excluded a hot-path read):
the predicate write reaches the driver with the security layer's Layer-0 tenant wall AND-composed onto the caller's filter (plugin-security step 3; tenant-layer.ts): under isolated an equality on the caller's active organization; under grouporganization_id IN accessibleOrgIds;
⇒ present when the wall names exactly one organization: isolated (the active organization), or group with a singleton membership set;
⇒ absent otherwise: single posture (no wall), an isSystem / unscoped context, a true PLATFORM_ADMIN crossing the wall, a group sweep across several memberships. ⛔ Never the caller's active organization as a substitute on a multi-membership group write — that is the mislabel PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's open question 1 rejected (option C).
Measure first whether the execution context at the publish site carries what this needs (tenantId, accessible_org_ids, isSystem — packages/spec/src/kernel/execution-context.zod.ts) and whether the posture is readable there without a lookup; if the honest reading is "the engine cannot know the wall it was composed under", say so in the report — that is a finding for the seam, not a reason to guess.
Pins: engine-data-events.test.ts — a tenant write under isolated publishes the key = the caller's organization; a system-context predicate write publishes the key absent; a group write with two memberships publishes it absent; BulkDataEventSchema.parse at the site stays the validator.
Sequencing
Same file as #14970 (engine.ts, the sibling publisher) — serial with it, never the same batch; whichever lands second merges main first. Not blocked on #13566 (the fan-out reads the key once both producers stamp it; that card is domain:services).
Refs: #13566 (p0 parent, domain:services, pm:blocked) · #14970 (single-record producer half) · #14971 / PR #15218 (the contract) · #14291 / PR #14635 (the single-record term and its open question 1)
Filed by the
domain:specexecution seat (sessionsession_01H2oQebDDxYKfWZusyd8GXk, seat post #6017) at the ACCEPT of PR #15218 (#14971, contract review 5536641884, 2026-09-04T06:31Z). Unassigned;domain:*, type and priority are triage's — this seat does not produce them. The parent leak ispriority:p0; this is its third path, so the emergency triage channel is requested rather than the hourly sweep.Named reader: whichever seat owns
packages/objectql(domain:engineon the current lane table) — the same seat that holds #14970 (the single-record producer half,pm:dispatchedat 06:31Z).Blocked-by: #14971
The gap
PR #15218 gives
BulkDataEventSchema(data.records.updated/data.records.deleted) an optionalorganizationId— one organization for the whole batch, or not asserted. The only bulk publisher,publishBulkDataEventinpackages/objectql/src/engine.ts(thedata.records.${action}site, reached from the predicateupdateMany/deleteManybranches), still builds the event as{ id, type, object, matched, userId, timestamp }and never sets the key. Until it does, every bulk event reads "not asserted", and under the contract a tenant-scoped consumer must then refuse to deliver it inside any organization wall — the fan-out closes safe but delivers nothing per-organization on the bulk path.Re-check (the line moves; the symbol does not):
What to build (the contract's producer obligation, from the merged JSDoc)
Stamp
organizationIdfrom what the producer already holds — ⛔ no second query on the publish path (triage 2026-08-31T12:47Z on #13566 excluded a hot-path read):plugin-securitystep 3;tenant-layer.ts): underisolatedan equality on the caller's active organization; undergrouporganization_id IN accessibleOrgIds;isolated(the active organization), orgroupwith a singleton membership set;singleposture (no wall), anisSystem/ unscoped context, a truePLATFORM_ADMINcrossing the wall, agroupsweep across several memberships. ⛔ Never the caller's active organization as a substitute on a multi-membershipgroupwrite — that is the mislabel PR feat(spec): DataEvent carries organizationId, the organization the record belongs to #14635's open question 1 rejected (option C).Measure first whether the execution context at the publish site carries what this needs (
tenantId,accessible_org_ids,isSystem—packages/spec/src/kernel/execution-context.zod.ts) and whether the posture is readable there without a lookup; if the honest reading is "the engine cannot know the wall it was composed under", say so in the report — that is a finding for the seam, not a reason to guess.Pins:
engine-data-events.test.ts— a tenant write underisolatedpublishes the key = the caller's organization; a system-context predicate write publishes the key absent; agroupwrite with two memberships publishes it absent;BulkDataEventSchema.parseat the site stays the validator.Sequencing
Same file as #14970 (
engine.ts, the sibling publisher) — serial with it, never the same batch; whichever lands second mergesmainfirst. Not blocked on #13566 (the fan-out reads the key once both producers stamp it; that card isdomain:services).Refs: #13566 (p0 parent,
domain:services,pm:blocked) · #14970 (single-record producer half) · #14971 / PR #15218 (the contract) · #14291 / PR #14635 (the single-record term and its open question 1)