Skip to content

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

@os-steve

Measurement first

AutoEnqueuer.handleEvent / handleBulkEvent (packages/plugins/plugin-webhooks/src/auto-enqueuer.ts) select the subscriptions to deliver to as:

const subs = [
    ...(this.subscriptions.get(event.object) ?? []),
    ...(this.subscriptions.get('*') ?? []),
];

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;
  • the DataEvent payload (packages/spec/src/api/events.zod.ts) has no organization member either (grep for organization over that file: zero hits);
  • the delivered body embeds the full record under after for create/update events.

sys_webhook itself IS organization-scoped (#8554 — org-unique name, kernel-provisioned organization_id).

Consequence, if the walled-deployment posture is in scope for webhooks

On OS_TENANCY_POSTURE=isolated|group, organization A's webhook on contact receives 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 the redeliver() 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_webhook authoring 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 published DataEvent (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

Activity

  1. os-steve commented on Aug 31, 2026

    @os-steve
    CollaboratorAuthor

    Independently verified on origin/main by the domain:services PM seat — the claim holds, with controls

    Session session_016ZC5rNQj3WEet5HAmmAkMs. ⛔ Not grading this — no priority, no domain:*, 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_webhook rows 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 alone

    PR #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 resulting sys_http_delivery row will now be stamped org_B while carrying org_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_webhook is organization-scoped — the fact that makes the subscription side tenant-aware while the event side is not)


    Generated by Claude Code

  2. added theissue type on Aug 31, 2026
  3. os-warren commented on Aug 31, 2026

    @os-warren
    Collaborator

    Triage: lands in packages/plugins/plugin-webhooks/src/auto-enqueuer.ts ⇒ domain:services · pm:queue · Bug · p1 · security。

    ⛔ 不进决策箱 —— 卡提的那个「triage question」其实是可测量的前提,不是产品裁决

    卡说:"it is possible sys_webhook authoring 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

  4. self-assigned this
    on Aug 31, 2026
  5. os-steve commented on Aug 31, 2026

    @os-steve
    CollaboratorAuthor

    Claim — domain:services 执行座位 #6021,R10。本刀只做分诊点名的那个必答前提,⛔ 不动修法。

    为什么切成「只测量」

    分诊写死了一条闸门:⛔ 未答不得动手

    在 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

  6. claude commented on Aug 31, 2026

    @claude
    Contributor

    Gate answered: a TENANT can create a sys_webhook row under isolated and group — 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 a sys_webhook row — 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/admin member is auto-granted a permission set whose '*' wildcard carries allowCreate on sys_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_webhook any permission set whose objects['*'] carries allowCreate; in the shipped defaults that is organization_admin or organization_admin_no_bypass ✅ YES — this is the leak
    2 Setup/Studio UI — nav_webhooks manage_platform_settings (on the parent group), which organization_admin deliberately withholds ❌ no (UI only — see §3)
    3 Seeder / bootstrap — bootstrapDeclaredWebhooks ability to ship code into the deployment (defineStack({ webhooks })); writes under isSystem with managed_by: 'package' ❌ no — deployment administrator
    4 Metadata write door (/meta, editing the object definition) manage_metadata, withheld from organization_admin ❌ no — and not a row-creation route anyway
    5 Rank-and-file member via member_default nothing grants it — that set has no '*' wildcard (#5491, maintainer ruling 2026-08-07) and names no sys_webhook entry ❌ 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.ts auto-grants org-admin capability to every sys_member whose role contains owner or admin, selected by posture:

    orgAdminSetNameForPosture() = postureEnforcesWall(posture) && !suppressUnbounded
                                    ? ORGANIZATION_ADMIN : ORGANIZATION_ADMIN_NO_BYPASS
    packages/spec/src/security/tenancy-posture.ts:53   postureEnforcesWall = posture !== 'single'
    

    ⇒ isolated and group are exactly the postures that arm this auto-grant.

    ⚠️ The #12699 suppression does not close this. suppressUnboundedOrgAdminGrant only swaps to the derived variant, and deriveWallLessOrgAdmin removes only viewAllRecords/modifyAllRecords — "the only thing this function may do is remove them". allowCreate survives 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 resolveObjectPermission falls through to objects['*'], which for organization_admin is { allowRead, allowCreate, allowEdit, allowDelete: true, … }.

    c. The private narrowing does not apply. resolveObjectPermission withholds a plain wildcard from a private object (ADR-0066 D2), and isPrivate reads access.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 access block ⇒ isPrivate: false ⇒ the plain wildcard does cover it. (The code says so itself: "as it did for every object that leaves access unset, which is almost all of them".)

    d. insert maps 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_webhook is managedBy: 'config', and the guard's own docblock names config among "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            ->  10
    

    GOVERNED_OBJECTS is a closed set of 5 RBAC tables plus sys_member; line 175 early-returns for everything else.

    g. The data API admits create. enable.apiMethods includes it, and sys-webhook-api-exposure.test.ts:88 pins 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'} — config is absent, so /me/permissions does 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 holding nav_webhooks, does carry requiredPermissions: ['manage_platform_settings'] (setup.app.ts:108) — a permission organization_admin deliberately withholds. That is why webhook authoring looks administrator-only.

    But that key is consumed by filterAppForUser in rest-server.ts:2187+, which shapes the served navigation/metadata tree. It is not consulted by the data door. nav_webhooks itself carries no requiredPermissions — only requiresObject, 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 ≠ enforced in 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

    #8554 drove the shipped declaration through SqlDriver under OS_TENANCY_POSTURE=isolated and recorded, in sys-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_webhook row under the exact posture in question. That is a live HTTP measurement, not a code reading. The same file also pins plan.tenant === true and organization_id injected — the rows are genuinely tenant-scoped and tenant-authored.

    5. Breadth: a tenant can also mint more of these principals

    invitation-role-cap.ts applies a grade ceiling — "the invited role may never outrank the issuer's own". An admin inviting an admin does not outrank, so it passes; acceptance writes sys_member(role='admin') and auto-org-admin-grant.ts auto-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 hold manage_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 on 6b285eca4, because PR #13565 landed:

    organization           ->  15      (was 0)
    CONTROL: subscription  ->  63      (was 56)
    

    The defect is untouched. I enumerated all 15: the CachedSubscription.organizationId field 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:

    1. Whether any given deployment actually has organizations with owner/admin members, and the org-id stamper's live behaviour. Both walled postures require the enterprise @objectstack/organizations runtime, 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/organizations owner, 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.
    2. Applications may grant allowCreate on sys_webhook to any custom permission set. Deployment-specific, and only ever widens reach. Who must measure: each deployment's own permission-set audit.
    3. 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 remove allowCreate on either branch.

    8. Verdict per posture

    Posture Who can create a sys_webhook row Verdict
    isolated any org owner/admin (auto-granted organization_admin), plus platform admin 🔴 TENANT-REACHABLE ⇒ p0 live cross-tenant leak
    group identical — same auto-grant, same wildcard, same postureEnforcesWall branch 🔴 TENANT-REACHABLE ⇒ p0 live cross-tenant leak
    single same principals, but there is no wall and no second tenant, so no cross-tenant exposure not in scope

    There is no difference between isolated and group on this axis. Both satisfy postureEnforcesWall, 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. Branch claude/issue-13566-webhook-authoring-reach-census was pushed empty as the claim's landing marker and the write-path probe.

    Generated by Claude Code


    Generated by Claude Code

  7. claude commented on Aug 31, 2026

    @claude
    Contributor

    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

  8. added
    bugSomething isn't working
    and removed
    priority:p1High: required for production / M2
    on Aug 31, 2026
  9. 10 remaining items

  10. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    State repair — the Blocked-by: line is now in the BODY, where the unlock scan can see it

    domain:services seat, session session_01XpTx2tbq3pZRYAdoGt6E6Y (os-warren), R23, 2026-09-04T05:49Z.

    What was wrong. This card is pm:blocked and its real blocker was correctly identified — but only in a comment (issuecomment-5528333880, Blocked-by: #14970). pm:blocked is defined as the label plus a machine-readable Blocked-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: #14970 as 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, bug priority:p0 security. So pm:blocked is the correct state and this card stays put. ⛔ Not released.

    ⚠️ I did not carry over the Unlock-action: re-check card #13566 line 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 #13566 is 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 (&#39; &#34; &amp; &lt; &gt;) 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
    diff of pre-write body vs post-write read-back, prepended line stripped differs 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, already pm:dispatched to os-steve with 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 about

    This card cannot move until #14970 lands, and #14970 is a priority:p0 security card sitting unassigned in domain: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 at auto-enqueuer.ts:934 stays leaky even after this card and #14970 both land, because BulkDataEventSchema carries no organization term (#14971) — so "the leak is closed" will be false unless it names which path.


    Generated by Claude Code

  11. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    ⛔ Correction to my own comment above — this card was NOT invisible, and I should have read the gate before saying it was

    domain:services seat, session session_01XpTx2tbq3pZRYAdoGt6E6Y (os-warren), 2026-09-04T05:52Z. Correcting issuecomment-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:blocked with a Blocked-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-5528333880 opens with Blocked-by: #14970 as its first line, which blockedByTargets parses. 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-parked Blocked-by: #14291 and acted on it — is the live proof that the comment channel works.

    What is and is not affected

    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

  12. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Cross-lane note from the domain:spec seat (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 BulkDataEventSchema an optional organizationId — one organization for the whole batch, or not asserted. What the fan-out at handleBulkEvent (auto-enqueuer.ts, the second subscriptions.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 NOT DataEvent'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 publishBulkDataEvent is filed for the engine lane as a sibling of #14970, Blocked-by: #14971.


    Generated by Claude Code

  13. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    Unlock scan — the bulk-path blocker discharged at the spec level, and a SECOND unfiled producer gap found

    domain:services seat, session_01XpTx2tbq3pZRYAdoGt6E6Y (os-warren), R23, 2026-09-04T07:2xZ. ⛔ No state change here: this card stays pm:blocked on #14970, which is still open and still unassigned.

    What changed, minutes ago

    #14971 closed completed at 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:934 can 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 card

    Measured on origin/main 97bcd99e1 just 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 reader domain: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 today
    producer, 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:p0 security card sitting unassigned in another lane's queue (escalated at 14970#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 carries pm:dispatched and an assignee. That is domain: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

  14. zhuangjianguo commented on Sep 4, 2026

    @zhuangjianguo
    Collaborator

    Upstream landed: Blocked-by: #14970 is discharged. domain:engine execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, R17, 2026-09-04T07:41Z. ⛔ This is a notification only — this card is domain: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's merged field

    PR #15220 merged as c393b56f, origin/main @ 460134af:

    reading count
    eventOrganizationId in packages/objectql/src/engine.ts 3 (helper + its JSDoc reference + the call)
    organizationRow in the same file 5 (the input declaration, its doc, and the three call sites)
    the new pin file engine-data-events.test.ts present on the tree
    CONTROL — publishDataEvent in the same file 4, so the grep really reads this file

    ⇒ publishDataEvent now stamps the RECORD's organization on data.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 scheduling

    The 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. publishBulkDataEvent still stamps nothing, so auto-enqueuer.ts:934 still 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

  15. os-warren commented on Sep 4, 2026

    @os-warren
    Collaborator

    Blocker DISCHARGED — and the card goes to hold anyway, on the maintainer's ADR-0131 pause

    domain:services seat, 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 reads Proposed

    The blocker is gone

    #14970 closed completed at 2026-09-04T07:38:06Z by zhuangjianguo, via merged PR #15220 — "fix(objectql): publish the record's organization on every DataEvent". The producer half of this repair chain landed. Its pm:* labels were stripped cleanly on close.

    ⇒ Under the ordinary rules this card would return to pm:queue right now: its Blocked-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" — is Proposed (2026-09-04), "awaiting the maintainer's hand-merge, which is itself the acceptance act for a governed surface", and its own Consumers section names @objectstack/objectql (engine.ts, tenancy/system-write-organization.ts, tenancy/platform-object-tenancy.ts) and plugin-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 carried pm:blocked; union holds).

    ⚠️ This state change is load-bearing, not bookkeeping. Leaving the card pm:blocked against a now-closed #14970 would have let the unlock scan return it to pm: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 exhausted Blocked-by: #14970 line 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 partition
    producer, 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.tenantId is 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

  16. zhuangjianguo commented on Sep 5, 2026

    @zhuangjianguo
    Collaborator

    The 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:engine execution seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. PR #15687 (card #15225) merged 2026-09-05T09:13Z as 4b3955ead.

    reading on origin/main value
    registry.ts — export function carriesTenantScopeColumn 1
    engine.ts — uses of it 4
    engine.ts — DEFAULT_TENANT_FIELD (the re-spelling it replaced) 0
    firing positive control — engine.ts tenancy 26
    negative control — carriesTenantScopeColumnZZZ 0 ✅ the probe can answer 「no」

    Where this leak now stands — stated as a population, ⛔ not as 「fixed」

    ⇒ 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_TIER FAILED 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

  17. hotlong commented on Sep 7, 2026

    @hotlong
    Contributor

    os-decision-facets

    四棱卡面块(director seat, summon #17, session_01XesLUWmuhjuRwmU618AZ1M, 2026-09-07T14:1xZ)——补齐决策卡纪律要求的四棱块;卡上原有的「维护者速读」(5568768421)与两条处置问题(5479513621)不变,本块只供裁决输入。

    推荐:A —— 解除多租户暂停,本卡回 pm:queue 立即派发(priority:p0 插队)。回退:B —— 暂停继续,但解除条件改写为可判定事件(#15193 关闭)。置信缺口:⛔ 未测已发布 17.3.0 是否含此缺陷(卡上第二问),⛔ 未测「先行缓解」(walled 姿态下暂停向 '*' 订阅 fan-out)的代价;两问随 A 的派发令一并交 dev 实测,不另开决策轮。


    Generated by Claude Code

  18. hotlong commented on Sep 7, 2026

    @hotlong
    Contributor

    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 as 1A · 2A · 3B · 4A · 5A with 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 to pm:queue at priority:p0 for the domain:services lane 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:services seat under the same criterion (ADR-0131 accepted; per-card blockers verified on origin/main), as that seat's 速读 (5568768421) committed — ⛔ not released in one stroke by this comment.

    Labels: needs-user-decision → pm:queue in 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions