⛔ Ungraded and unrouted — domain:*, priority and type are triage's; this needs a maintainer ruling on shape, not a queue position. Filed by the domain:engine execution seat, unassigned.
⚠️ This blocks a priority:p0 security card. #15225 is the bulk producer half of the #13566 cross-tenant webhook leak. Its delivery (PR #15687) is otherwise sound and its contract review at CONTRACT_REVIEW_TIER FAILED on exactly this gap (verdict 5549091499), with the reviewer's own words: 「this PR does not merge while the carve-out population can be mislabelled」.
The gap, measured
publishBulkDataEvent must stamp BulkDataEventSchema.organizationId only when the Layer 0 tenant wall named exactly one organization. To decide that, the engine re-derives the wall's own input. But plugin-security folds three clauses into that input (security-plugin.ts, getObjectSecurityMeta):
tenancyDisabled:
(obj as any)?.tenancy?.enabled === false ||
(obj as any)?.systemFields?.tenant === false ||
(this.orgScopingEnabled &&
this.deploymentOrgScopingEntitlement().platformGlobalObjects.has(object)),
and its own docblock states the consequence: 「a deployment-exempted object behaves exactly as if it had declared tenancy: { enabled: false } itself, on every one of those paths at once」.
The third clause is declared by the deployment, not by the object — so the engine cannot see it at any price. Measured on origin/main:
| reading |
value |
packages/objectql/src/engine.ts mentions platformGlobalObjects |
0 |
…mentions deploymentOrgScopingEntitlement |
0 |
firing control — same file, tenancy |
20 |
the pinned consequence (deployment-platform-global-exemption.test.ts) |
exempted object, isolated, MEMBER ⇒ getReadFilter returns undefined — no wall |
⇒ On such an object the producer stamps the caller's active organization while Layer 0 constrained nothing, so the batch can span several organizations. The reviewer measured the sibling case end to end (systemFields:{tenant:false} + a declared organization_id, isolated, MEMBER, rows across org_acme / org_globex / org-less): organizationId: 'org_acme', matched: 3, while computeTenantLayer0Filter returns null for the same input.
Why a mislabel is the bad direction here, and why this cannot be patched in the engine
An absent key under-delivers: a tenant-scoped consumer declines to fan out — the safe failure, and today's behaviour. A wrong key asserts 「every affected record belongs to exactly this organization」 falsely, and the consumer then delivers a cross-organization batch inside the wrong wall — the #13566 leak, reappearing through the PR that closes its bulk half.
The schema-level clause (systemFields.tenant === false) is fixable inside the engine and is item 1 of that PR's patch round. The deployment clause is not: nothing in the object's schema carries it. Any engine-side fix would be a third copy of a predicate that already exists in plugin-security, and the mirroring is what produced this defect.
The decision
Two shapes, both from the contract review's escalation; ⛔ the seat does not choose between them.
- (i)
plugin-security records its Layer 0 verdict on the operation, and the publish site reads it. Collapses the mirrored derivation entirely — the engine stops re-deriving the wall and reads what the wall decided. Closes the schema clause, the deployment clause, and both 「conservative absent」 populations (a carried PLATFORM_ADMIN rung on a public tenant object; a non-system context carrying no rung, whose exemption is decided by a permission-set probe the engine cannot see) in one move. Cost: a new member on the operation context — an additive widening of a published surface ⇒ at least minor on its declaring package, and its own contract review.
- (ii) A per-object 「is this object walled here?」 provider, injected beside the existing
setTenancyPostureProvider. Smaller and symmetric with what already exists; closes the deployment clause but leaves the two rung-shaped populations answered by conservatism.
⚠️ Both are cross-lane: the producer is domain:engine, plugin-security is domain:services (references/lanes/services.md:11). That is why this is a ruling rather than a dispatch.
Sequencing, so the p0 is not silently parked
PR #15687's patch round for the other six items is dispatched and proceeds in parallel; it does not wait on this. What waits on this is the merge: while the carve-out population can be mislabelled, the PR stays draft with needs:contract-review hung on both carriers. If this becomes its own card, #15225 takes Blocked-by it.
⚠️ Note what is not being claimed: the leak's single-record half (#14970, landed) is unaffected, and the bulk path today emits no key at all, so nothing regresses while this is decided — the risk is only in landing the fix as written.
Re-check
git show origin/main:packages/plugins/plugin-security/src/security-plugin.ts | grep -n -A5 'tenancyDisabled:'
git show origin/main:packages/objectql/src/engine.ts | grep -c platformGlobalObjects # expect 0
git show origin/main:packages/objectql/src/engine.ts | grep -c tenancy # positive control
Dedup: ⚠️ /search/issues answers 403 in this container and an empty search is not a reading — repo-scoped REST list of open cards plus a local grep; no card carries platformGlobalObjects with the producer/seam shape. Related: #13566 (p0 parent) · #15225 (this producer half) · #14970 (single-record half, landed) · #12699 (the deployment carve-out) · PR #15687 · contract review 5549091499.
Generated by Claude Code
⛔ Ungraded and unrouted —
domain:*, priority and type are triage's; this needs a maintainer ruling on shape, not a queue position. Filed by thedomain:engineexecution seat, unassigned.priority:p0securitycard. #15225 is the bulk producer half of the #13566 cross-tenant webhook leak. Its delivery (PR #15687) is otherwise sound and its contract review atCONTRACT_REVIEW_TIERFAILED on exactly this gap (verdict5549091499), with the reviewer's own words: 「this PR does not merge while the carve-out population can be mislabelled」.The gap, measured
publishBulkDataEventmust stampBulkDataEventSchema.organizationIdonly when the Layer 0 tenant wall named exactly one organization. To decide that, the engine re-derives the wall's own input. Butplugin-securityfolds three clauses into that input (security-plugin.ts,getObjectSecurityMeta):and its own docblock states the consequence: 「a deployment-exempted object behaves exactly as if it had declared
tenancy: { enabled: false }itself, on every one of those paths at once」.The third clause is declared by the deployment, not by the object — so the engine cannot see it at any price. Measured on
origin/main:packages/objectql/src/engine.tsmentionsplatformGlobalObjectsdeploymentOrgScopingEntitlementtenancydeployment-platform-global-exemption.test.ts)isolated,MEMBER⇒getReadFilterreturnsundefined— no wall⇒ On such an object the producer stamps the caller's active organization while Layer 0 constrained nothing, so the batch can span several organizations. The reviewer measured the sibling case end to end (
systemFields:{tenant:false}+ a declaredorganization_id,isolated,MEMBER, rows acrossorg_acme/org_globex/ org-less):organizationId: 'org_acme',matched: 3, whilecomputeTenantLayer0Filterreturnsnullfor the same input.Why a mislabel is the bad direction here, and why this cannot be patched in the engine
An absent key under-delivers: a tenant-scoped consumer declines to fan out — the safe failure, and today's behaviour. A wrong key asserts 「every affected record belongs to exactly this organization」 falsely, and the consumer then delivers a cross-organization batch inside the wrong wall — the #13566 leak, reappearing through the PR that closes its bulk half.
The schema-level clause (
systemFields.tenant === false) is fixable inside the engine and is item 1 of that PR's patch round. The deployment clause is not: nothing in the object's schema carries it. Any engine-side fix would be a third copy of a predicate that already exists inplugin-security, and the mirroring is what produced this defect.The decision
Two shapes, both from the contract review's escalation; ⛔ the seat does not choose between them.
plugin-securityrecords its Layer 0 verdict on the operation, and the publish site reads it. Collapses the mirrored derivation entirely — the engine stops re-deriving the wall and reads what the wall decided. Closes the schema clause, the deployment clause, and both 「conservative absent」 populations (a carriedPLATFORM_ADMINrung on a public tenant object; a non-system context carrying no rung, whose exemption is decided by a permission-set probe the engine cannot see) in one move. Cost: a new member on the operation context — an additive widening of a published surface ⇒ at leastminoron its declaring package, and its own contract review.setTenancyPostureProvider. Smaller and symmetric with what already exists; closes the deployment clause but leaves the two rung-shaped populations answered by conservatism.domain:engine,plugin-securityisdomain:services(references/lanes/services.md:11). That is why this is a ruling rather than a dispatch.Sequencing, so the p0 is not silently parked
PR #15687's patch round for the other six items is dispatched and proceeds in parallel; it does not wait on this. What waits on this is the merge: while the carve-out population can be mislabelled, the PR stays draft with
needs:contract-reviewhung on both carriers. If this becomes its own card, #15225 takesBlocked-byit.Re-check
Dedup:⚠️
/search/issuesanswers 403 in this container and an empty search is not a reading — repo-scoped REST list of open cards plus a local grep; no card carriesplatformGlobalObjectswith the producer/seam shape. Related: #13566 (p0 parent) · #15225 (this producer half) · #14970 (single-record half, landed) · #12699 (the deployment carve-out) · PR #15687 · contract review5549091499.Generated by Claude Code