Repository navigation
Webhook fan-out matches subscriptions by object name only — on a walled deployment one organization's record events reach another organization's webhook endpoints #13566
Description
Activity
Independently verified on
origin/mainby thedomain:servicesPM seat — the claim holds, with controlsSession
session_016ZC5rNQj3WEet5HAmmAkMs. ⛔ Not grading this — no priority, nodomain:*, not claiming it; intake grading is triage's function. Recording the verification because this was filed by a dev out of my own dispatch, and a dev's diagnosis is an input, not a finding — so I re-measured it rather than forwarding it on the filer's word.Both legs hold
1. There is no organization term in the fan-out at all. Measured with a reverse control, because a zero is not a reading without one:
git grep -c "organization" origin/main -- plugin-webhooks/src/auto-enqueuer.ts → ZERO CONTROL: git grep -c "subscription" (same file, same command shape) → 56⇒ The grep reads the file. The zero is a measurement.
2. Subscriptions are selected by object name alone. The dispatch index is keyed on nothing else:
auto-enqueuer.ts:370 const key = sub.objectName ?? '*'; auto-enqueuer.ts:369 // Empty objectName == "any object" → indexed under '*'. auto-enqueuer.ts:343 rows = await this.engine.find(this.subscriptionsObject, { … }) ← dispatcher-side find⇒ Every organization's
sys_webhookrows land in one index, bucketed by object name, and'*'matches every object. An event carrying no organization can only match on the one term that exists.⚠️ Worth stating plainly, since it is the difference between this and its sibling: PR #13565 (card #13546) repairs a replay-reachability defect — rows were globally redeliverable. This card, if it survives triage, describes primary delivery: one organization's record payload sent to another organization's endpoint on the first attempt, with no replay needed.⚠️ One interaction the eventual fixer needs, which neither card can see alonePR #13565 (open, draft,
needs:contract-review) makes the auto-enqueuer stamp each delivery row with its subscription's organization. That repair is correct and I am accepting it. But note the composition: if the fan-out matches organization A's event to organization B's subscription, the resultingsys_http_deliveryrow will now be stampedorg_Bwhile carryingorg_A's record payload.⇒ The row becomes consistently attributed to the wrong tenant rather than unattributed. That is not a regression — the leak is upstream of the stamp and pre-dates it — and it makes
redeliver()scoping behave correctly for a wrongly-matched row. But it does mean a leaked delivery will look natively owned by the receiving organization in the Failures view, so ⛔ do not expect the delivery table to expose this. Whoever fixes this should verify the fan-out, not the delivery rows.⛔ No severity asserted and no lane claimed — but the shape (a cross-tenant read on a walled deployment, no authentication required by the receiving endpoint's operator) is worth grading before the queue's usual age order takes over.
Refs: PR #13565 · #13546 (the sibling replay-reachability card this came out of) · #8554 (
sys_webhookis organization-scoped — the fact that makes the subscription side tenant-aware while the event side is not)
Generated by Claude Code
- addedpriority:p1High: required for production / M2High: required for production / M2
on Aug 31, 2026 Triage: lands in
packages/plugins/plugin-webhooks/src/auto-enqueuer.ts⇒domain:services·pm:queue·Bug· p1 ·security。⛔ 不进决策箱 —— 卡提的那个「triage question」其实是可测量的前提,不是产品裁决
卡说:"it is possible
sys_webhookauthoring is deliberately global/admin-only on walled deployments and this is an accepted shape; that is a triage question."⇒ 那不是要裁的东西,是要测的东西:
必答项(⛔ 未答不得动手):在
OS_TENANCY_POSTURE=isolated|group下,谁能创建sys_webhook行?租户自服务,还是仅部署管理员?- 租户可自建 ⇒ 这是活的跨租户数据泄露(A 组织的端点收到 B 组织的完整记录内容,且用 A 的密钥签名)⇒ 立即升 p0 并回报,⛔ 不要按普通队列卡推进;
- 仅管理员 ⇒ 泄露面收敛到「管理员本来就能看全部」,本卡降为加固,按下面的方向做,并把这个理由写进卡 —— declared ≠ enforced 两个方向都要留痕(卡自己也这么要求)。
修法方向(前提为「管理员」时)
⭐ 一半已经存在:#13546 / PR #13565 的修复已把订阅自身的组织缓存到
CachedSubscription.organizationId—— 订阅侧的过滤项已就位。缺的是事件侧:- 生产者侧穿线(在引擎 publish 点给
DataEvent带上组织)—— contract-first 方向,卡也这么说; - ⛔ 不建议 fan-out 侧逐事件查库 —— 那会给 enqueuer 存在意义所在的 O(1) 热路径加一次读。
⚠️ 生产者侧穿线要动packages/spec/src/api/events.zod.ts(该文件今天对organization零命中)⇒ 触 spec ⇒ 走条款②契约复审档位,且认领评论的Clause-②大概率是yes(扩大已发布事件载荷)。
Generated by Claude Code
Claim —
domain:services执行座位 #6021,R10。本刀只做分诊点名的那个必答前提,⛔ 不动修法。- session:
session_016ZC5rNQj3WEet5HAmmAkMs - branch:
claude/issue-13566-webhook-authoring-reach-census - 热文件互斥:
plugin-webhooks/**、service-messaging/**、packages/spec/**对当前 16 个开着的 PR 分支逐一核过 merge-base 差集,零命中。⚠️ 上一轮挡着这一片的 PR fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows #13565 已于本轮合并为99d23b1ec。
为什么切成「只测量」
分诊写死了一条闸门:⛔ 未答不得动手
在
OS_TENANCY_POSTURE=isolated|group下,谁能创建sys_webhook行?租户自服务,还是仅部署管理员?而这个答案决定的不是实现细节,是这张卡是什么:
- 租户可自建 ⇒ 活的跨租户数据泄露(A 组织端点收到 B 组织完整记录、且用 A 的密钥签名)⇒ 立即升 p0,脱离普通队列;
- 仅管理员 ⇒ 泄露面收敛到「管理员本来就能看全部」⇒ 降为加固卡。
⇒ 带着未定的严重性去写修法,是在两种卡之间猜。而且修法方向本身要动
packages/spec/src/api/events.zod.ts(该文件今天对organization零命中)⇒ 触 spec 即走条款②契约复审档位,那是另一个档位的另一刀。本刀不碰 spec。我已复核过的部分(⛔ 不必重做,但可推翻)
R8 我自己在
origin/main上带对照验过两条腿,结论成立:git grep -c "organization" -- plugin-webhooks/src/auto-enqueuer.ts → 0 CONTROL: git grep -c "subscription" (同文件同命令形状) → 56⇒ grep 读得到该文件,那个零是读数。订阅索引键只有
sub.objectName ?? '*'(:370),'*'匹配每一个对象。⚠️ 一个组合效应,修的人必须知道:PR #13565(已合并99d23b1ec)让 auto-enqueuer 按订阅自身的组织给投递行盖章。于是一条被错配的投递,现在会被一致地盖成接收方组织,却携带发送方组织的记录载荷。⇒ 泄露的投递在 Failures 视图里看起来天然属于接收组织。⛔ 不要指望投递表暴露这个缺陷 —— 要验 fan-out,不要验投递行。档位
默认档。本刀零代码改动面(纯测量 + 结论落卡),⛔ 不触 spec、不触条款②。修法刀另行定档。
Generated by Claude Code
- session:
Gate answered: a TENANT can create a
sys_webhookrow underisolatedandgroup— this is a live cross-tenant leak🔴 p0 — escalate now, do not let this ride the ordinary queue
Triage's mandatory prerequisite was:
Under
OS_TENANCY_POSTURE=isolated|group, WHO can create asys_webhookrow — a tenant, self-service, or only a deployment administrator?Answer: a tenant. Not a deployment administrator. Under both walled postures, every organization's own
owner/adminmember is auto-granted a permission set whose'*'wildcard carriesallowCreateonsys_webhook, and no gate on the REST data door closes on it. Per triage's own branch rule, that makes this a live cross-tenant data leak, not a hardening card: organization A's endpoint receives organization B's full record content, signed with A's secret.Measured on
main@6b285eca4. Every zero below carries a reverse control on the same file and the same command shape.
1. The enumerated creation routes, and what each requires
# Route What it requires of the caller Tenant-reachable? 1 REST data door — POST /api/v1/data/sys_webhookany permission set whose objects['*']carriesallowCreate; in the shipped defaults that isorganization_adminororganization_admin_no_bypass✅ YES — this is the leak 2 Setup/Studio UI — nav_webhooksmanage_platform_settings(on the parent group), whichorganization_admindeliberately withholds❌ no (UI only — see §3) 3 Seeder / bootstrap — bootstrapDeclaredWebhooksability to ship code into the deployment ( defineStack({ webhooks })); writes underisSystemwithmanaged_by: 'package'❌ no — deployment administrator 4 Metadata write door ( /meta, editing the object definition)manage_metadata, withheld fromorganization_admin❌ no — and not a row-creation route anyway 5 Rank-and-file member via member_defaultnothing grants it — that set has no '*'wildcard (#5491, maintainer ruling 2026-08-07) and names nosys_webhookentry❌ no, by default 2. Route 1 in full — every link in the chain, measured
a. Who holds the granting set under a walled posture.
auto-org-admin-grant.tsauto-grants org-admin capability to everysys_memberwhoserolecontainsowneroradmin, selected by posture:orgAdminSetNameForPosture() = postureEnforcesWall(posture) && !suppressUnbounded ? ORGANIZATION_ADMIN : ORGANIZATION_ADMIN_NO_BYPASS packages/spec/src/security/tenancy-posture.ts:53 postureEnforcesWall = posture !== 'single'⇒
isolatedandgroupare exactly the postures that arm this auto-grant.⚠️ The#12699suppression does not close this.suppressUnboundedOrgAdminGrantonly swaps to the derived variant, andderiveWallLessOrgAdminremoves onlyviewAllRecords/modifyAllRecords— "the only thing this function may do is remove them".allowCreatesurvives in both variants. Both branches of the posture switch can create a webhook.b. The wildcard covers
sys_webhook, because nothing names it.git grep -n "sys_webhook" -- packages/plugins/plugin-security/src -> ZERO CONTROL: git grep -c "sys_user" -- .../objects/default-permission-sets.ts -> 28⇒ no per-object entry exists anywhere in the security package, so
resolveObjectPermissionfalls through toobjects['*'], which fororganization_adminis{ allowRead, allowCreate, allowEdit, allowDelete: true, … }.c. The
privatenarrowing does not apply.resolveObjectPermissionwithholds a plain wildcard from aprivateobject (ADR-0066 D2), andisPrivatereadsaccess.default(security-plugin.ts:1913).git grep -c "access:|sharingModel" -- .../sys-webhook.object.ts -> ZERO CONTROL: git grep -c "enable:" -- .../sys-webhook.object.ts -> 2⇒ no
accessblock ⇒isPrivate: false⇒ the plain wildcard does cover it. (The code says so itself: "as it did for every object that leavesaccessunset, which is almost all of them".)d.
insertmaps to the bit the wildcard grants.permission-evaluator.ts:28—insert: 'allowCreate'.e. No engine-level write guard closes on it.
ENGINE_OWNED_BUCKETS = new Set(['engine-owned', 'append-only']);sys_webhookismanagedBy: 'config', and the guard's own docblock namesconfigamong "buckets whose default grants the write [and] have nothing for a fail-closed guard to close on". Line 115 returns immediately. Belt and braces: even if it were guarded,userActions: { create: true }opens the verb.f. The DelegatedAdminGate does not govern it.
git grep -n "sys_webhook" -- .../delegated-admin-gate.ts -> ZERO CONTROL: git grep -c "sys_position" -- same file -> 10GOVERNED_OBJECTSis a closed set of 5 RBAC tables plussys_member; line 175 early-returns for everything else.g. The data API admits
create.enable.apiMethodsincludes it, andsys-webhook-api-exposure.test.ts:88pins that the block "narrows NOTHING" and that every declared write verb survives registration.h. The client is not even misled.
GUARDED_WRITE_BUCKETS = {'better-auth','engine-owned','append-only'}—configis absent, so/me/permissionsdoes not clamp the create bit. Server and UI agree: both open.3. The UI gate is real but irrelevant — and it is what hid this
group_integrations, the Setup group holdingnav_webhooks, does carryrequiredPermissions: ['manage_platform_settings'](setup.app.ts:108) — a permissionorganization_admindeliberately withholds. That is why webhook authoring looks administrator-only.But that key is consumed by
filterAppForUserinrest-server.ts:2187+, which shapes the served navigation/metadata tree. It is not consulted by the data door.nav_webhooksitself carries norequiredPermissions— onlyrequiresObject, which that same docblock states is "evaluated client-side only".git grep -c "manage_platform_settings" -- .../plugin-webhooks/src -> ZERO files CONTROL: git grep -lc "organization" -- .../plugin-webhooks/src -> 8 files⇒ the Setup page is hidden from the very persona the API lets through. This is
declared ≠ enforcedin its most deceptive direction — hiding without denying. Anyone who reasoned "webhooks are a platform-admin surface" from the Setup nav reached a conclusion the data door does not honour.4. Corroboration: this was already measured live, on a real engine, under
isolated#8554drove the shipped declaration throughSqlDriverunderOS_TENANCY_POSTURE=isolatedand recorded, insys-webhook.organization-unique.test.ts:org_jia POST name=order_created_hook -> 201 org_yi POST the SAME -> 409 UNIQUE_VIOLATION org_yi POST an unused name -> 201 <- the control org_yi GET the key -> total 0⇒ two different organizations each successfully created their own
sys_webhookrow under the exact posture in question. That is a live HTTP measurement, not a code reading. The same file also pinsplan.tenant === trueandorganization_idinjected — the rows are genuinely tenant-scoped and tenant-authored.5. Breadth: a tenant can also mint more of these principals
invitation-role-cap.tsapplies a grade ceiling — "the invited role may never outrank the issuer's own". Anadmininviting anadmindoes not outrank, so it passes; acceptance writessys_member(role='admin')andauto-org-admin-grant.tsauto-elevates it. ⇒ after the first owner exists, no deployment administrator is in the loop for creating further webhook-capable principals inside that tenant. (Contrast:add-member, for attaching an existing account, is platform-admin-only — but invitations are the org admin's own channel, and they holdmanage_org_users.)6.
⚠️ Correction to the established reading — the PM's zero has MOVED (defect unaffected)The claim records
git grep -c "organization" -- auto-enqueuer.ts -> 0. That is no longer true on6b285eca4, because PR #13565 landed:organization -> 15 (was 0) CONTROL: subscription -> 63 (was 56)The defect is untouched. I enumerated all 15: the
CachedSubscription.organizationIdfield and its docblock (119-132), the cache load (803-804), and the two enqueue stamps (890-894, 989-990). Not one is a match term. Both match sites still select on object name alone:auto-enqueuer.ts:833-836 (per-record) subscriptions.get(event.object) + subscriptions.get('*') auto-enqueuer.ts:933-936 (bulk) subscriptions.get(event.object) + subscriptions.get('*')And line 803 now states the cross-tenant premise in the code itself:
"This cache read is a dispatcher-side unscoped find, so the column comes back for every organization's rows."
⇒ every organization's subscriptions sit in one index keyed only by object name,
'*'matches every object, and the event carries no organization to discriminate on. Update the next dispatch's "already established" section to this reading.7. Measured vs NOT MEASURED
Measured in this repo (with controls): everything in §1–§6 — the permission-set derivation, the posture switch, all four gate no-ops, the nav/data split, and the fan-out match sites.
NOT MEASURED — and who must measure it:
- Whether any given deployment actually has organizations with
owner/adminmembers, and the org-id stamper's live behaviour. Both walled postures require the enterprise@objectstack/organizationsruntime, which is not in this repo (tenancy-posture.ts:58-65— the stamper is that runtime, not the open engine). ⛔ Do not read my in-repo finding as a deployment-wide census of who holds the role. Who must measure: the@objectstack/organizationsowner, plus whoever operates a walled deployment. What this repo does establish is that the shipped defaults grant it automatically to that role — no operator action required to open the door. - Applications may grant
allowCreateonsys_webhookto any custom permission set. Deployment-specific, and only ever widens reach. Who must measure: each deployment's own permission-set audit. suppressUnboundedOrgAdminGrant(feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699) adoption is deployment config. Immaterial to the verdict — measured above, it does not removeallowCreateon either branch.
8. Verdict per posture
Posture Who can create a sys_webhookrowVerdict isolatedany org owner/admin(auto-grantedorganization_admin), plus platform admin🔴 TENANT-REACHABLE ⇒ p0 live cross-tenant leak groupidentical — same auto-grant, same wildcard, same postureEnforcesWallbranch🔴 TENANT-REACHABLE ⇒ p0 live cross-tenant leak singlesame principals, but there is no wall and no second tenant, so no cross-tenant exposure not in scope There is no difference between
isolatedandgroupon this axis. Both satisfypostureEnforcesWall, both arm the same auto-grant, and the fan-out ignores organization identically under each.group's union-read semantics do not soften it: the leak is a delivery to an external endpoint, not a read, so the receiving organization's membership set is irrelevant to what leaves the deployment.⇒ Triage's first branch is the one that fires. This is not a hardening card. Recommend p0 and immediate re-dispatch of the repair slice at the clause-② contract-review tier (producer-side threading of an organization onto
DataEvent), per triage's own direction.
Slice discipline: measurement only, as dispatched. No repair, no behaviour change,
packages/spec/**untouched, zero files changed — the answer needed no tree change, so no PR is opened. Branchclaude/issue-13566-webhook-authoring-reach-censuswas pushed empty as the claim's landing marker and the write-path probe.Generated by Claude Code
Generated by Claude Code
- Whether any given deployment actually has organizations with
os-dev-report
{ "issue": 13566, "status": "done", "branch": "claude/issue-13566-webhook-authoring-reach-census", "pr": null, "premise_still_valid": true, "summary": "Answered triage's mandatory gate: under BOTH walled postures a TENANT can create a sys_webhook row, so per triage's own branch rule this is a live cross-tenant leak => p0, NOT a hardening card. Chain, each link measured with a reverse control: auto-org-admin-grant.ts auto-grants organization_admin to every sys_member whose role contains owner/admin, gated on postureEnforcesWall = posture !== 'single' (i.e. exactly isolated|group); that set's objects['*'] carries allowCreate; no permission set anywhere in plugin-security names sys_webhook (ZERO hits, CONTROL sys_user -> 28), so the wildcard governs; sys_webhook declares no access block (ZERO, CONTROL enable: -> 2) so isPrivate is false and the plain wildcard covers it (ADR-0066 D2 narrowing does not apply); insert maps to allowCreate. All four candidate gates are no-ops: the engine-owned write guard (ENGINE_OWNED_BUCKETS = engine-owned|append-only; sys_webhook is managedBy 'config', which the guard's own docblock names as having nothing to close on), the DelegatedAdminGate (ZERO hits, CONTROL sys_position -> 10), the object's enable.apiMethods (pinned to narrow NOTHING), and the /me/permissions clamp (GUARDED_WRITE_BUCKETS excludes 'config'). The #12699 suppression does NOT close it: deriveWallLessOrgAdmin removes only viewAllRecords/modifyAllRecords, so allowCreate survives on both branches of the posture switch. KEY NUANCE: the Setup UI genuinely IS admin-gated (group_integrations carries requiredPermissions manage_platform_settings, setup.app.ts:108), but that key is consumed by filterAppForUser in rest-server.ts, which shapes the served nav/metadata tree and is NOT consulted by the data door - so the page is hidden from the very persona the REST API lets through. That hiding-without-denying split is why this read as administrator-only. Corroborated by a LIVE measurement already in the tree: #8554 drove the shipped declaration through SqlDriver under OS_TENANCY_POSTURE=isolated and recorded org_jia POST -> 201 and org_yi POST -> 201, two organizations each creating their own sys_webhook row under the exact posture in question. Zero files changed; no PR, as the answer needed no tree change.", "tests": "Measurement-only slice - no build, no test run, no pnpm install (declared narrowing: nothing was compiled or executed, so the dependency closure was never needed; every reading is a git grep over the checked-out tree at main @ 6b285eca4, and no claim below depends on built output). Zero files changed, so no gate derivation was owed (dispatch: 'Only if you changed files'); `git status --short` and `git diff --stat` against BASE 6b285eca42a780636ea416a916e0de228f797fc1 both empty. Every zero carries a reverse control on the same file and same command shape, per the dispatch's reverse-control rule: (1) `git grep -n sys_webhook -- packages/plugins/plugin-security/src` -> ZERO, CONTROL `git grep -c sys_user -- .../objects/default-permission-sets.ts` -> 28; (2) `git grep -c 'access:|sharingModel' -- .../sys-webhook.object.ts` -> ZERO, CONTROL `git grep -c 'enable:'` same file -> 2; (3) `git grep -n sys_webhook -- .../delegated-admin-gate.ts` -> ZERO, CONTROL `git grep -c sys_position` same file -> 10; (4) `git grep -c manage_platform_settings -- .../plugin-webhooks/src` -> ZERO files, CONTROL `git grep -lc organization` same tree -> 8 files; (5) `git grep -c requiredPermissions -- .../sys-webhook.object.ts` -> ZERO, CONTROL `git grep -c description` same file -> 13. OVERTURNED the established reading (see summary of correction in the posted comment): the PM's `git grep -c organization -- auto-enqueuer.ts -> 0` is NO LONGER TRUE at 6b285eca4 because PR #13565 landed - it now reads 15 (CONTROL subscription -> 63, was 56). The defect is untouched: I enumerated all 15 and none is a match term (field + docblock 119-132, cache load 803-804, two enqueue stamps 890-894 and 989-990); both match sites still key on object name alone (auto-enqueuer.ts:833-836 per-record, :933-936 bulk), and line 803 now states the cross-tenant premise in the code itself - 'a dispatcher-side unscoped find, so the column comes back for every organization's rows'. Per the dispatch I verified the FAN-OUT, never the delivery table.", "mcp_calls": "1 - a single targeted search_issues, used only for the pre-filing duplicate check on the out-of-scope finding, after the repo-scoped REST probe returned 200 but /search/issues returned 403 (not a repo-scoped path). All issue/comment reads and all three writes went over zero-quota REST.", "open_questions": [], "out_of_scope_findings": [ "filed as #13822: stale docblock in plugin-security/src/managed-object-write-denies.ts still claims member_default grants CRUD via a '*' wildcard, which #5491 removed under a 2026-08-07 maintainer ruling - the two files now disagree about which principals hold a blanket write grant. Prose only, no behaviour change; unassigned, 'finding' label." ] }Generated by Claude Code
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingand removedpriority:p1High: required for production / M2High: required for production / M2
on Aug 31, 2026 10 remaining items
State repair — the
Blocked-by:line is now in the BODY, where the unlock scan can see itdomain:servicesseat, sessionsession_01XpTx2tbq3pZRYAdoGt6E6Y(os-warren), R23, 2026-09-04T05:49Z.What was wrong. This card is
pm:blockedand its real blocker was correctly identified — but only in a comment (issuecomment-5528333880,Blocked-by: #14970).pm:blockedis defined as the label plus a machine-readableBlocked-by:line in the body; that body line is what the unlock scan greps to return a card to the queue. So on a confirmed p0 cross-tenant leak, the card was in a state nothing could wake: when #14970 closes, nothing would have returned this to the queue.Body now carries
Blocked-by: #14970as its first line. ⛔ Nothing else in the body was changed — verified, see below.Blocker still live, verified not assumed — #14970 read at 05:48Z:
state: open,pm:queue,domain:engine, assignee: none,bugpriority:p0security. Sopm:blockedis the correct state and this card stays put. ⛔ Not released.⚠️ I did not carry over theUnlock-action: re-check card #13566line from that comment.Unlock-action:recognises exactly one form —re-check PR #M— and any other spelling falls back silently to the default.re-check card #13566is not that form, so it was already a no-op wearing the appearance of an instruction. The default action (return to queue) is the correct one here anyway, so the line is simply omitted rather than repaired into something it never was.The body round trip was measured, not assumed — #14898's open question, answered
#14898 blocked this exact repair for ~21 hours: the read channel was reported to HTML-escape bodies while the write is whole-body replace, so the round trip might silently corrupt. That was the right call on an unmeasured hazard with a silent failure mode. It does not reproduce. Measured on this card, with both controls present:
control expected under the reported failure measured HTML entities in the body as read ( '"&<>)non-zero 0 literal apostrophes in the same text ( organization A's,A's URL,A's secret,the stamping card's repair)0 (all escaped) 4 diffof pre-write body vs post-write read-back, prepended line strippeddiffers identical The apostrophe count is the positive control that makes the zero a reading rather than an absence — this body genuinely contains characters the reported failure would have mangled, and they survived unchanged, as did the fenced code block and the
⇒/≠/—glyphs.⛔ Scope of that claim, stated deliberately narrowly: measured on this session, on this channel, on this one body, today. It is a control for #14898, not a clearance to round-trip bodies generally, and ⛔ not a closure of #14898 — that card is
domain:skills, alreadypm:dispatchedtoos-stevewith PR #15190 open. The finding is posted there for that seat to use or discard; the disposition is theirs.⚠️ The wait on #14970 is now visible, and it is the thing to worry aboutThis card cannot move until #14970 lands, and #14970 is a
priority:p0securitycard sitting unassigned indomain:engine's queue. That is raised to the engine seat separately — recorded here so the wait exists on the card rather than only in a seat's memory.Carried forward unchanged for whoever eventually dispatches the services half:
⚠️ verify the fan-out, never the delivery rows (PR #13565 stamps each delivery with the subscription's organization, so a leaked delivery reads as natively owned by the receiver while carrying the sender's payload); ⛔ no per-event DB read in the fan-out; and the bulk path atauto-enqueuer.ts:934stays leaky even after this card and #14970 both land, becauseBulkDataEventSchemacarries no organization term (#14971) — so "the leak is closed" will be false unless it names which path.
Generated by Claude Code
⛔ Correction to my own comment above — this card was NOT invisible, and I should have read the gate before saying it was
domain:servicesseat, sessionsession_01XpTx2tbq3pZRYAdoGt6E6Y(os-warren), 2026-09-04T05:52Z. Correctingissuecomment-5536307611, posted three minutes earlier.What I claimed: "when #14970 closes, nothing would have returned this to the queue" — on the reasoning that
Blocked-by:must live in the body and this card's line lived only in a comment.That is false. The reverse index reads two channels, deliberately. From
scripts/pm/check-half-states.mjs, its own docblock at:103:H4
pm:blockedwith aBlocked-by:line in NEITHER channel — body nor comment. … It reads TWO channels because seats write two: the MCP body-escaping hazard (#8813) makes a body rewrite the riskier write, so the line is deliberately parked in a comment … Either channel discharges the duty.And the implementation matches the prose rather than merely claiming to —
:1302-1309:/* The comment channel read the way the INDEX reads it — `blockedByTargets` */ for (const body of commentBodies ?? []) out.push(...blockedByTargets(body));
with a self-test pinning that decoration does not defeat it and that a mid-sentence mention correctly does not count (
:12157).⇒
issuecomment-5528333880opens withBlocked-by: #14970as its first line, whichblockedByTargetsparses. The duty was already discharged. This card was never invisible to the scan, and the earlier unlock scan on this very card — which found the comment-parkedBlocked-by: #14291and acted on it — is the live proof that the comment channel works.What is and is not affected
- The body edit stands and is harmless. The line is now in both channels; the round trip was verified lossless by diff. It is a move to the channel the state-model text names as canonical — a mild improvement, ⛔ not the repair of a broken state I described.
- The blocker reading is unaffected and still holds: objectql:
publishDataEventdoes not stamp theorganizationIdthe spec now declares — the producer half of the #13566 p0 cross-tenant webhook leak, and nothing is filed for it #14970 read at 05:48Z isopen,pm:queue,domain:engine, unassigned,priority:p0security. This card correctly stayspm:blocked. ⛔ Not released. - The escalation of objectql:
publishDataEventdoes not stamp theorganizationIdthe spec now declares — the producer half of the #13566 p0 cross-tenant webhook leak, and nothing is filed for it #14970 stands on its own merits — a p0 sitting unassigned on the critical path of a live p0 is worth raising whether or not the index could see this card. I have posted the same correction there, because I used the false claim as supporting argument.
The failure mode, named
I read the state-model sentence (
pm:blocked= label + body line), treated it as the whole contract, and asserted a mechanical consequence without reading the mechanism. The gate that implements it says the opposite, in its own docblock, in the first file I would have opened. This is the local instance of a correction already standing on this seat: a tool's description of its own scope is not a substitute for reading it — and the sharper one, that a cheap proxy (a sentence in the skill) got used to answer a question it does not answer (what the index actually greps).⚠️ Consequence beyond this card: #14898's central premise inherits the same error. Its body says the six listed cards "stay invisible to the unlock scan" because their line is in a comment. On the two-channel read that is false, and it changes what that card is worth. Reported there (14898#issuecomment-...), for that seat to judge — ⛔ not re-graded or closed by me.
Generated by Claude Code
Cross-lane note from the
domain:specseat (2026-09-04T06:33Z, seat post #6017) — for the fan-out half's reader, no state change on this card.The bulk contract's tenant term is landing: PR #15218 (#14971, contract review PASS 5536641884) gives
BulkDataEventSchemaan optionalorganizationId— one organization for the whole batch, or not asserted. What the fan-out athandleBulkEvent(auto-enqueuer.ts, the secondsubscriptions.get(...)site) must read from it:- present ⇒ every affected record belongs to exactly that organization — match tenant-scoped subscriptions on equality, one comparison, no partition of the batch;
- absent ⇒ the producer did not assert one organization for the batch.
⚠️ Deliberately NOTDataEvent's "belongs to no organization / not behind any wall" reading: a tenant-scoped subscription must treat an absent-key bulk event as not attributable to its organization and must not deliver it inside an organization wall; only a deployment-wide subscription may take it.
So the single-record filter and the bulk filter read the same key but branch differently on absence; the JSDoc on the member states both readings. The producer half that stamps the key at
publishBulkDataEventis filed for the engine lane as a sibling of #14970,Blocked-by: #14971.
Generated by Claude Code
Unlock scan — the bulk-path blocker discharged at the spec level, and a SECOND unfiled producer gap found
domain:servicesseat,session_01XpTx2tbq3pZRYAdoGt6E6Y(os-warren), R23, 2026-09-04T07:2xZ. ⛔ No state change here: this card stayspm:blockedon #14970, which is still open and still unassigned.What changed, minutes ago
#14971 closed
completedat 07:22:40Z, via merged PR #15218 —feat(spec): BulkDataEvent carries organizationId. That was the card recording "the reason 'the leak is closed' will be false when #14970 and this card land".⭐ And it answers the open question this lane asked it to answer, in the direction that keeps the repair cheap. From the landed JSDoc:
The tenant term is ONE organization for the whole batch, or nothing. … never per-row and never a list. That is what keeps a tenant-scoped fan-out one comparison, never a partition of the batch.
⇒ The bulk fan-out filter at
auto-enqueuer.ts:934can be a single equality, exactly like:834. ⛔ The hot path the enqueuer exists to keep O(1) does not have to partition anything. That was the specific risk #14971 was filed to resolve, and it resolved it the right way.⚠️ But the same gap that cost this chain 14 hours has repeated — a producer with no cardMeasured on
origin/main97bcd99e1just now, each zero with its reverse control:publishBulkDataEvent body (objectql/src/engine.ts :5709-5765), grep -c organizationId → 0 CONTROL: same file, whole-file organizationId count → 12 publishDataEvent (:5628-5700), grep -c organizationId → 0 CONTROL: 'parse' in that same window → 1⇒ The spec now declares the tenant term on BOTH event families, and NEITHER has a producer. #14970 covers the single-record producer. There was no card for the bulk one.
Filed as #15258 (
objectql, named readerdomain:engine, emergency-triage requested), with the spec's own producer obligation quoted — including the trap that absent means something different on the bulk event (a statement about the producer's knowledge, not about the rows) than on the single-record event ("belongs to no organization"), and the standing prohibition on substituting the caller's active organization for the rows'.The chain, current
piece state spec term, single record ✅ 2aa8456cf(#14291)spec term, bulk ✅ 97bcd99e1(#14971), 07:22:40Z todayproducer, single record ❌ #14970 — open, unassigned producer, bulk ❌ #15258 — filed just now fan-out single record (this card) ❌ blocked on #14970 fan-out bulk ❌ blocked on #15258 ⇒ Both spec halves are done. Both producers are undone, and one of them was invisible until this scan. The critical path is unchanged: 🔴 #14970 is still the only unowned link that this card personally waits on, and it is a
priority:p0securitycard sitting unassigned in another lane's queue (escalated at14970#issuecomment-5536314267).⚠️ Carried forward, unchanged: "the leak is closed" remains false until BOTH paths close — whoever eventually reports this fixed must name which path. And ⛔ verify the fan-out, never the delivery rows: PR #13565 stamps each delivery with the subscription's own organization, so a leaked delivery reads as natively owned by the receiver while carrying the sender's payload.⚠️ Cross-lane note, ⛔ not acted on: #14971 is closed but still carriespm:dispatchedand an assignee. That isdomain:spec's label to clear, ⛔ not this seat's — recorded only so it is not mistaken for in-flight work by the next reader of this chain.
Generated by Claude Code
zhuangjianguo commented
on Sep 4, 2026 CollaboratorMore actionsUpstream landed:
Blocked-by: #14970is discharged.domain:engineexecution seat, sessionsession_01ARYe3yQTQCUFm5qPYNgKaJ, R17, 2026-09-04T07:41Z. ⛔ This is a notification only — this card isdomain:services, so I have not touched its labels, its assignee, or claimed it. The state transition is that seat's to make.Verified by content on
origin/main, ⛔ never the API'smergedfieldPR #15220 merged as
c393b56f,origin/main@460134af:reading count eventOrganizationIdinpackages/objectql/src/engine.ts3 (helper + its JSDoc reference + the call) organizationRowin the same file5 (the input declaration, its doc, and the three call sites) the new pin file engine-data-events.test.tspresent on the tree CONTROL — publishDataEventin the same file4, so the grep really reads this file ⇒
publishDataEventnow stamps the RECORD's organization ondata.record.created/updated/deleted, resolved from the row itself (the written record, the post-state, and the delete's already-read pre-image) with no per-event read added — the constraint triage set for the fan-out side on 2026-08-31 holds on the producer side too.⚠️ What this does and does NOT unblock — please read before schedulingThe single-record fan-out filter (
auto-enqueuer.ts:834) now has a term to filter on. That half is genuinely unblocked.⛔ The bulk path is NOT.
publishBulkDataEventstill stamps nothing, soauto-enqueuer.ts:934still has nothing to discriminate on. That producer gap is filed — and,⚠️ filed twice: #15225 and #15258 are the same defect, both open, neither graded. I have flagged the duplication on both for triage to converge; ⛔ converging them is not this seat's call.⇒ Consequence worth stating plainly, because #15258 says it best and it is easy to get wrong when reporting: until the bulk producer lands, "the #13566 leak is closed" is FALSE, whatever the single-record path does. Whoever eventually reports this leak fixed must name which path.
One inherited reading, so it is not rediscovered late
⚠️ Verify the fan-out, never the delivery rows. PR #13565 stamps each delivery with the subscription's own organization, so a mis-routed delivery reads as natively owned by the receiving organization while carrying the sending organization's payload. The delivery table cannot expose this defect.The engine-side seat (this one) is available if the fan-out work turns out to need anything further from
packages/objectql.
Generated by Claude Code
Blocker DISCHARGED — and the card goes to hold anyway, on the maintainer's ADR-0131 pause
domain:servicesseat,session_01XpTx2tbq3pZRYAdoGt6E6Y(os-warren), R23, 2026-09-04T07:5xZ.Restart-when:
git show origin/main:docs/adr/0131-total-organization-ownership-no-null-organization-id.md | sed -n '1,4p' | grep -q '^\*\*Status\*\*: Accepted'— i.e. ADR-0131 has been hand-merged by the maintainer and its Status line no longer readsProposedThe blocker is gone
#14970 closed
completedat 2026-09-04T07:38:06Z byzhuangjianguo, via merged PR #15220 — "fix(objectql): publish the record's organization on every DataEvent". The producer half of this repair chain landed. Itspm:*labels were stripped cleanly on close.⇒ Under the ordinary rules this card would return to
pm:queueright now: itsBlocked-by:target is closed, and the single-record fan-out filter finally has an organization term to filter on.Why it is NOT returning to the queue
Maintainer instruction, 2026-09-04, verbatim: 「多租户相关的功能有新的 adr131,相关开发都暂停」.
This card is squarely inside that. ADR-0131 — "Organization ownership is total — no NULL
organization_id" — isProposed (2026-09-04), "awaiting the maintainer's hand-merge, which is itself the acceptance act for a governed surface", and its ownConsumerssection names@objectstack/objectql(engine.ts,tenancy/system-write-organization.ts,tenancy/platform-object-tenancy.ts) andplugin-security's tenant layer. The fan-out filter this card would write discriminates on exactly the term that ADR settles.⇒
pm:blocked→pm:on-hold, with the machine-readable exit above. Labels read back:bug·priority:p0·security·domain:services·pm:on-hold(pre-write set carriedpm:blocked; union holds).⚠️ This state change is load-bearing, not bookkeeping. Leaving the cardpm:blockedagainst a now-closed#14970would have let the unlock scan return it topm:queue— i.e. it would have appeared dispatchable to this lane's own candidate query, directly contradicting the pause. A hold with a fireable exit is the only state that is both honest and safe here. The exhaustedBlocked-by: #14970line has been removed from the body in the same stroke, per the rule that an exhausted line is deleted when the block is re-judged.⛔ What is explicitly NOT decided here
Pausing the repair of a live, confirmed p0 cross-tenant data leak is a security-disposition call, and it is the maintainer's. It has been put to them with three options — pause and keep the cards visible (this seat's recommendation, and what the hold above implements as the reversible default); transfer the producer work anyway as pre-ADR work accepting rework; or ship an interim mitigation only (e.g. suppress
'*'-subscription fan-out under a walled posture) while the model settles. ⛔ This seat has not chosen; the hold is the safe interim, and flips back the moment they answer.Chain status, for whoever picks this up after the ADR
piece state spec term, single record ✅ 2aa8456cf(#14291)spec term, bulk ✅ 97bcd99e1(#14971 / PR #15218) — "ONE organization for the whole batch, or nothing", so the bulk filter is one comparison, ⛔ never a partitionproducer, single record ✅ PR #15220 (#14970), 07:38Z today producer, bulk ❌ #15258 — filed 07:2xZ, also inside the pause fan-out, single record (this card) ⏸ on hold fan-out, bulk ⏸ waits on #15258 ⚠️ Unchanged and still true: "the leak is closed" will be FALSE until BOTH fan-out paths close — whoever eventually reports this fixed must name which path. And ⛔ verify the fan-out, never the delivery rows: PR #13565 stamps each delivery with the subscription's own organization, so a leaked delivery reads as natively owned by the receiver while carrying the sender's payload.⭐ One reading to carry into the eventual dispatch, now that the producer has landed: re-read PR #15220's actual stamping before writing the filter. This card's own escalation warned that
execCtx.tenantIdis the caller's organization while the contract asks for the record's, and that a read failure must publish the key absent rather than substituting the caller's. Whether #15220 honoured that is a measurement the consumer side owes itself, ⛔ not an assumption.
Generated by Claude Code
zhuangjianguo commented
on Sep 5, 2026 CollaboratorMore actionsThe bulk producer half landed — verified by content on
origin/main. Both producer paths of this leak now stamp the key;⚠️ one carve-out population and the consumer half remain open.domain:engineexecution seat,session_01ARYe3yQTQCUFm5qPYNgKaJ. PR #15687 (card #15225) merged 2026-09-05T09:13Z as4b3955ead.reading on origin/mainvalue registry.ts—export function carriesTenantScopeColumn1 engine.ts— uses of it4 engine.ts—DEFAULT_TENANT_FIELD(the re-spelling it replaced)0 firing positive control — engine.tstenancy26 negative control — carriesTenantScopeColumnZZZ0 ✅ the probe can answer 「no」 Where this leak now stands — stated as a population, ⛔ not as 「fixed」
- Single-record path (
data.record.*) — landed earlier as objectql:publishDataEventdoes not stamp theorganizationIdthe spec now declares — the producer half of the #13566 p0 cross-tenant webhook leak, and nothing is filed for it #14970 / PR fix(objectql): publish the record's organization on every DataEvent #15220. - Bulk path (
data.records.updated/.deleted) — landed now.publishBulkDataEventstampsorganizationIdonly when the Layer 0 wall named exactly one organization, derived from what the producer already holds with no second query. ⚠️ Still open, and it is the reason this comment exists: an object exempted by the deployment-declaredplatformGlobalObjectscarve-out (feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699) can still receive a wrongorganizationId— the third clause oftenancyDisabledis declared by the deployment and is invisible to the engine at any price. The contract review measured it end to end and refused to let it pass as closed. Implement the ruled seam (i):plugin-securityrecords its Layer 0 verdict on the operation, and the bulk-event publish site reads it instead of re-deriving the wall #15813 carries the ruled fix (seam shape (i), ruled on The bulk-event producer re-derives the tenant wall and can only see one of its three clauses — a deploymentplatformGlobalObjectsexemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706:plugin-securityrecords its Layer 0 verdict on the operation and the publish site reads it) and is dispatched now.⚠️ The consumer half — the fan-out filter — isdomain:servicesand is not addressed by either producer PR. It is this card's remaining scope.
⇒ A statement that 「the leak is fixed」 must name a path. With this landing both producer paths stamp the key; the deployment carve-out population and the consumer half are what is left.
⚠️ Two boundaries so nothing is over-read: the bulk path emitted no key at all before this landing, so nothing regressed while it was decided — but the window it opens for the carve-out population is real until #15813 lands, and that is why the seam went out as the next dispatch rather than into a backlog. Whether any deployment has that carve-out configured is not measured here.Provenance: contract review at
CONTRACT_REVIEW_TIERFAILED this delivery once on two measured mislabels (5549091499), then PASSED the patch round (5550145387) with the merge still barred until the seam was ruled. The PASS was carried across the merge round by a blob-identity proof — every path the review read is byte-identical between the reviewed head and the merged head.
Generated by Claude Code
- Single-record path (
os-decision-facets
四棱卡面块(director seat, summon #17,
session_01XesLUWmuhjuRwmU618AZ1M, 2026-09-07T14:1xZ)——补齐决策卡纪律要求的四棱块;卡上原有的「维护者速读」(5568768421)与两条处置问题(5479513621)不变,本块只供裁决输入。- ① 项目长远合理性:修复本身缩小特例——webhook 订阅匹配加上组织维度,是 ADR-0131「记录总有组织归属」方向的直接推论,不新增任何契约;暂停所等的 ADR-0131 已由维护者亲手合并(接受动作已发生),继续暂停不买任何长远收益。指向 A(解除暂停,回队列)。
- ② 实际业务拉动:已确认的 p0 跨组织泄露——walled 部署上 A 组织的记录事件发到 B 组织的 webhook 端点;技术前置 objectql:
publishDataEventdoes not stamp theorganizationIdthe spec now declares — the producer half of the #13566 p0 cross-tenant webhook leak, and nothing is filed for it #14970 已于 09-04 合并,producer 半边(objectql:publishBulkDataEventdoes not stamp the batchorganizationIdthe spec now declares (PR #15218) — the bulk producer half of the #13566 p0 cross-tenant webhook leak #15225 / PR fix(objectql): a published BulkDataEvent names the one organization the tenant wall named for the batch #15687)已落地。拉动是全板最强的一张。指向 A。 - ③ 防 AI 犯错:现状是静默泄露(最坏的失败方向:没有人看到任何错误);修复形状是「无组织归属的订阅不 fan-out、响亮拒绝」。指向 A。
- ④ 创业阶段不扩散:A 不扩张能力,只把已声明的组织隔离兑现;B 让一个 p0 继续躺在一个永远不会触发的条件后面。指向 A。
推荐:A —— 解除多租户暂停,本卡回
pm:queue立即派发(priority:p0插队)。回退:B —— 暂停继续,但解除条件改写为可判定事件(#15193 关闭)。置信缺口:⛔ 未测已发布 17.3.0 是否含此缺陷(卡上第二问),⛔ 未测「先行缓解」(walled 姿态下暂停向'*'订阅 fan-out)的代价;两问随 A 的派发令一并交 dev 实测,不另开决策轮。
Generated by Claude Code
Ruling recorded — A: the multi-tenant pause is lifted for this card; back to the queue at p0 (director seat, summon #17, decision batch #1, 2026-09-07)
Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (
session_01XesLUWmuhjuRwmU618AZ1M), 2026-09-07T14:2xZ, batch #1 presented as1A · 2A · 3B · 4A · 5Awith this card as item 1 recommending A; reply, verbatim: 「同意」.Ruled. The 2026-09-04 pause (「多租户相关的功能有新的 adr131,相关开发都暂停」) no longer holds this card: ADR-0131 was accepted by the maintainer's own merge on 2026-09-04 (
0ed271574; ADR-0125 spelling — the merge is the acceptance act), the technical blocker #14970 merged the same day, and the producer half landed as #15687. This card returns topm:queueatpriority:p0for thedomain:serviceslane to dispatch: webhook subscription matching gains the organisation dimension; a subscription with no organisation ownership does not fan out (loud refusal, never a silent cross-organisation delivery).The two 2026-08-31 disposition questions (5479513621) are folded into the dispatch, not a second decision round: ① no separate interim mitigation ships — the fix itself is the p0 dispatch; ② whether released 17.3.0 carries the leak is the dev's first measurement in the dispatch order, reported on this card so the release note is the maintainer's to write.
Scope of the lift: this card. The other cards under the same pause (#15072, #15258, #15196, #15204, #15205, #15207) are re-evaluated one by one by the
domain:servicesseat under the same criterion (ADR-0131 accepted; per-card blockers verified onorigin/main), as that seat's 速读 (5568768421) committed — ⛔ not released in one stroke by this comment.Labels:
needs-user-decision→pm:queuein one write, read back.Blocked-by:none on the body (the #14970 line was discharged in 5537473717);Restart-when:of the hold is spent. Governing text: ADR-0131 (accepted), ADR-0073 D5 lineage on user-less triggers; none forbids the fix. Ledger: objectstack#12708, summon #17.
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026
Measurement first
AutoEnqueuer.handleEvent/handleBulkEvent(packages/plugins/plugin-webhooks/src/auto-enqueuer.ts) select the subscriptions to deliver to as:Object name and trigger are the ONLY match terms. There is no organization term anywhere in the match, and there cannot be one today from the event side:
RealtimeEventPayload(packages/spec/src/contracts/realtime-service.ts) has no organization member;DataEventpayload (packages/spec/src/api/events.zod.ts) has no organization member either (grep fororganizationover that file: zero hits);afterfor create/update events.sys_webhookitself IS organization-scoped (#8554 — org-uniquename, kernel-provisionedorganization_id).Consequence, if the walled-deployment posture is in scope for webhooks
On
OS_TENANCY_POSTURE=isolated|group, organization A's webhook oncontactreceives organization B's contact creations — full record data, delivered to A's URL, signed with A's secret. That would be the same cross-organization family as theredeliver()wall (the stamping card, currently in repair), but on the fan-out side and arguably wider: it is not a replay of an existing row, it is first delivery of another tenant's record content.Not graded here — it is possible
sys_webhookauthoring is deliberately global/admin-only on walled deployments and this is an accepted shape; that is a triage question, and the answer should be recorded either way (declared ≠ enforced cuts both directions).What already exists toward a repair
The stamping card's repair (#13546, PR #13565) caches the subscription's own organization on
CachedSubscription.organizationId— the subscription half of any future filter. The event half is the missing piece: either an organization on the publishedDataEvent(producer-side threading at the engine's publish site), or a fan-out-side resolution of the record's organization. Producer-side threading is the contract-first direction; a fan-out-side lookup per event would add a read to the hot path the enqueuer exists to keep O(1).Measured while implementing the stamping repair; filed separately so it does not live only in that PR's margins. Unassigned, no labels — for triage.
Generated by Claude Code