Skip to content

tenant-audit: updateMany on tenant-scoped sys_http_delivery / sys_notification_delivery runs without options.tenantId — un-isolated writes on multi-org shapes #10673

Description

@os-zhuang

Platform half of objectstack-ai/cloud#1512 (filed by the triage seat; the hotcrm crm_contract half stays on that card). Measured on a composed multi-org boot (EE 4.1.0 image, OS_TENANCY_POSTURE=isolated), the tenant-audit instrument prints:

[tenant-audit] updateMany on tenant-scoped object "sys_http_delivery" without
options.tenantId — writes will not be tenant-isolated.
[tenant-audit] updateMany on tenant-scoped object "sys_notification_delivery" without
options.tenantId — writes will not be tenant-isolated.

These are the platform's own delivery objects (webhook / notification delivery bookkeeping). On single-org shapes the missing scope is harmless; on multi-org shapes an unscoped updateMany is a cross-tenant write.

What the fix must decide, per call site

Find the updateMany call sites for these two objects (webhooks / notifications delivery maintenance paths) and, for each, either:

  • pass the request's options.tenantId (if the operation is tenant-contextual), or
  • mark it bypassTenantAudit only if it is genuinely a global maintenance task (e.g. env-wide retention sweep) — with a comment saying why.

Silencing the audit line without classifying the write is not a fix; the audit exists to force exactly this classification.

Repro

EE 4.1.0 image + deploy/composed.env.example + a composed artifact boot; read docker compose logs app during startup. Green = no [tenant-audit] line for these two objects, or an explicit, justified bypass at each remaining site.

Activity

  1. added theissue type on Aug 21, 2026
  2. self-assigned this
    on Aug 21, 2026
  3. os-warren commented on Aug 21, 2026

    @os-warren
    Collaborator

    Claim: PM domain:services 派发

    • Session: 0f14f70b-575c-5f2b-a235-4000a55db042
    • Branch: claude/issue-10673-delivery-updatemany-tenant-scope
    • Worktree: ../objectstack-10673(per-repo;⛔ 不在共享主检出上编辑;⛔ 不用 git stash)
    • File surface: sys_http_delivery / sys_notification_delivery 的 updateMany 调用点所在包(预期在 packages/plugins/plugin-webhooks / plugin-notifications 一带;以你实测到的为准)+ 同包测试。⛔ 零 packages/spec 所有权(车道红线);⛔ 不碰 content/docs/releases/。
    • Container & model: claude-opus-5
    • Clause-②: 我的首判是 no —— 这是租户边界的收紧,不是对外接受/拒绝集的改动,也不扩公开面。安全边界的放宽才是维护者地板,收紧不是。
      ⚠️ 若你测出某个调用点补上 tenantId 后改变了某个对外可观测的接受/拒绝行为(例如一条维护路径从「全部成功」变成「部分不再命中」并向调用方报错),停下并说出来,不要自行降级处理。

    ⚠️ 卡面给的复现路径你多半跑不了 —— 这是本卡最容易出假证据的地方

    卡面的 repro 是「EE 4.1.0 镜像 + deploy/composed.env.example + composed 构件启动 + docker compose logs app」。如果你的容器里跑不起来,就不要假装跑过。

    • 允许的替代:从 [tenant-audit] 仪器自身的判据出发 —— 找到它在树内的实现,读出它究竟以什么条件判定「tenant-scoped 对象上的 updateMany 缺 options.tenantId」,然后在单元层面把这两个对象的调用点喂给同一判据,量出它今天确实报警、改完之后不再报警。
    • ⛔ 不允许:只凭 grep 到 updateMany( 就宣布找齐了调用点。零命中不是发现,除非邻近的已知存在词证明搜索本身有效;同样,一个改动前就存在的词返回非零也什么都不证明 —— 挑改动特有的标记。
    • 如果连仪器判据都无法在本地行使,如实声明为收窄:说清没测到什么、为什么、替代物是什么。声明出来的收窄是诚实的;编一条 docker 日志不是。

    实现要求

    1. 逐调用点分类,这就是本卡的实质。卡面说得很清楚:「在不分类这次写入的前提下消掉审计行,不是修复;这条审计存在的意义正是逼出这个分类。」
      每个调用点只有两条合法出路:
      • 传入请求的 options.tenantId(该操作是租户上下文相关的),或
      • 标 bypassTenantAudit,仅当它确实是全环境的维护任务(如 env-wide 保留期清扫),并写明为什么。
        ⛔ 禁止第三条路:为了让日志变干净而给一个其实是租户相关的写入贴 bypass。
    2. 分类要有依据,不是感觉。对每个调用点,说出它的调用链上游是谁、有没有请求上下文可取。「看起来像维护任务」不是理由;「它由 X 定时器触发、没有请求上下文、且按 Y 的语义必须跨租户」才是。
    3. 双向 pin:① 租户相关的调用点在补上 tenantId 后只命中本租户的行;② 另一个租户的行没被动(这一条才是本卡的实质,只钉①的套件在一个「谁都不动」的实现上也会绿);③ 被判定为 bypass 的调用点仍然按设计跨租户工作,且审计不再对它报警。
    4. 消融:预测签名先写下来再跑,双向;git hash-object 证明恢复逐字节一致。说明(并用阳性对照证明,不是断言)测试解析的是 src/ 还是构建产物 dist/。
    5. 上游卡的另一半不归你:objectstack-ai/cloud#1512 上的 crm_contract 半边留在那张卡上。⛔ 不要越界,也不要在本 PR 里提出对它的修法。
    6. Changeset 必须有(.changeset/*.md,一个文件)。⛔ 绝不往 content/docs/releases/ 追加。
    7. 门禁并集:node scripts/pm/dispatch-gates.mjs 不带路径参数推导,在最终提交之后、干净工作树上跑;退出码在任何管道之前捕获(cmd > f 2>&1; EXIT=$?,绝不在 tail 之后)。引用每道门禁自己那行判词。
      ⚠️ class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309:推导出的并集已知会短。 额外显式跑 pnpm check:route-envelope 与 pnpm check:dispatcher-error-vocabulary(各带 --self-test),并报告推导是否点名了它们。
    8. ⛔ @objectstack/plugin-auth 的 TEST_DEBT 记 109([finding] @objectstack/plugin-auth's TEST_DEBT records 109 where tsc now reports 98 — an 11-error surplus opened by PR #10614 #10615):不要动、不要跑 --lower。
    9. 开 draft PR(Fixes #10673),git push -u origin <branch>(网络错误按 2s/4s/8s/16s 退避重试)。⛔ 不翻 ready、⛔ 不武装 auto-merge、⛔ 不合并 —— PM 先审。

    ⚠️ CI 上一个不是你的红

    check-query-options-erasure-ratchet 可能红在 packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded(#10449)。⛔ 不要为它改代码;重跑不超过一次;仍红就注明并指向 #10449。
    (一条反向读数供参考:本车道另一个席位刚在本地跑出该门禁 self-test 打印 registry.ts parses at --stack-size=4000 through this gate's own channel —— 即本地未复现。若你也未复现,如实说,不要照抄这条警告。)


    Generated by Claude Code

  4. os-warren commented on Aug 21, 2026

    @os-warren
    Collaborator
    {
      "issue": 10673,
      "status": "done",
      "branch": "claude/issue-10673-delivery-updatemany-tenant-scope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10725",
      "premise_still_valid": true,
      "summary": "Card premise confirmed by measurement, not assumed: on OS_TENANCY_POSTURE=isolated the SQL driver's auditMissingTenant gate emits both quoted lines verbatim for sys_http_delivery and sys_notification_delivery, and both objects really do resolve tenantField='organization_id'. The census found exactly six multi:true (=> driver.updateMany) writes on these two objects, all six on the dispatcher claim path (visibility-timeout reap + atomic claim, in SqlHttpOutbox.claim, SqlNotificationOutbox.claim and SqlNotificationOutbox.claimDigest). All six classify as genuine global environment sweeps, on a structural warrant: their only callers are the two dispatchers' setInterval tick under a cluster lock (no request context), ClaimOptions/HttpClaimOptions carry no tenant even in principle, partitioning is a load-spreading hash rather than an org key, and one outbox per environment drains the whole queue - a per-org predicate would strand every other org's deliveries forever. They now pass bypassTenantAudit through one documented helper (outbox-dispatcher-scope.ts) carrying that warrant, with a per-site pointer comment. Clause-2 re-measured as NO: the flag is diagnostics-only and the tests pin that the rows touched are unchanged. ZERO packages/spec ownership; content/docs/releases untouched; plugin-auth TEST_DEBT untouched and --lower never run.",
      "tests": "All results below are at final commit 72607c0 on a clean tree; exit codes captured before any pipe. (1) Instrument criterion, measured before the fix through a real ObjectQL + driver-sql better-sqlite3 harness: both card lines reproduced verbatim - '[tenant-audit] updateMany on tenant-scoped object \"sys_http_delivery\" without options.tenantId ...' and the same for sys_notification_delivery - plus resolveTenantField = 'organization_id' for both objects. The card's composed EE boot was NOT run (declared narrowing, see open_questions). (2) New test packages/services/service-messaging/src/delivery-claim-tenant-audit.integration.test.ts: 4 tests, each pinning three directions - no audit line for the classified write; both orgs' rows still claimed/reaped with organization_id intact (so a touch-nothing or per-org implementation goes red); and a positive control that a deliberately unscoped multi:true write on the SAME object through the SAME driver still produces the audit line (run after the assertion it guards, because the gate throttles one warning per object-and-op pair). (3) Ablation, signature predicted in writing BEFORE mutating: flipping bypassTenantAudit true->false in src/ with NO rebuild predicted exit 1, 'Tests 4 failed (4)', all four on expect(auditedUpdateMany(...)).toBe(false) with 'expected true to be false', and zero 'positive control failed'. Observed: exactly that (4x 'AssertionError: expected true to be false' at test lines 149/169/195/214; grep -c 'positive control failed' = 0). REBUILD STATEMENT: no build was run on either leg, deliberately - the subject resolves through src/ (relative import inside the package) while the INSTRUMENT resolves through dist/ (unaliased workspace dep, recorded in scripts/check-test-source-alias.mjs KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/service-messaging']), so a RED with no build IS the positive control for src-resolution rather than an assertion about it. Restore leg: git checkout HEAD -- <file>; git hash-object = e8e59054e802ecd60344682d6f46d20cf8bea6c8 = the HEAD blob, git status --porcelain empty (byte-identical), re-run exit 0 'Tests 4 passed (4)'. (4) Package suite: 'Test Files 24 passed (24)', 'Tests 248 passed (248)'; typecheck 'tsc --noEmit' exit 0 with the script name echoed (no zero-match pnpm filter). (5) Gate union derived by 'node scripts/pm/dispatch-gates.mjs' with NO path arguments, after the final commit on a clean tree - 5 paths, 9 path-matched + 6 convention-triggered families. All 17 gates run, all green, quoting each gate's own verdict line: check-nul-bytes 'OK (scanned 6222 text file(s) ... no raw ASCII control bytes)'; check-test-source-alias 'OK - 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/'; check-type-source-resolution 'OK - 76 packages with a tsconfig.json scanned'; check-engine-double-contract 'OK - 371 pinned, 133 in the DEBT ledger, 2 exempt'; where-matcher 'conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly'; slot-lookup 'ratchet holds: 107 unswept site(s) in 25 file(s), none new'; check-i18n-bundles 'OK (9 package(s) - all bundles in sync, no undeclared authoring keys)'; check-type-check-coverage 'OK - 64/77 workspace packages type-checked' and '--re-measure: OK - 33 ledger entr(ies) re-measured in 408.2s, 1913 raw tsc error(s) total, none above its recorded number'; check-adr-0087-registration 'this PR adds no declared-breaking changeset'; check-changeset-no-major 'This diff introduces no major bump'; check-empty-changeset 'No empty-frontmatter changeset introduced by this diff'; affected-docs 'self-test: 308 cases pass'; objectui-changeset 'objectui-range --self-test: all checks passed'; changeset-gate-self-tests all three self-tests pass. NOTE on check:i18n - its FIRST run exited 1 with 'PREREQUISITE NOT MET - the workspace CLI is not built ... Nothing was checked'; that is not a failure verdict, so the full workspace build was run (turbo 'Tasks: 70 successful, 70 total') and the gate re-run green. class #10309 RECORD: the derivation did NOT name check:route-envelope or check:dispatcher-error-vocabulary; both were run explicitly with --self-test and both pass ('check-route-envelope self-test passed'; 'check-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK'). All heavy runs went through scripts/pm/os-verify-lock.sh.",
      "open_questions": [
        {
          "question": "DECLARED NARROWING, not a question needing a decision - stated in full so it is on the record. The card's repro (EE 4.1.0 image + deploy/composed.env.example + composed artifact boot + docker compose logs app) was NOT run: this container has no docker and no EE image, so nothing about a composed multi-org boot is claimed and no docker log is quoted. What stands in its place is the instrument's own criterion read from source (packages/drivers/driver-sql/src/sql-driver.ts, auditMissingTenant, ~line 9906) and exercised end to end: OS_TENANT_AUDIT != '0', bypassTenantAudit !== true, tenantId empty/absent, isMultiTenantMode() via postureEnforcesWall(resolveTenancyPosture()), resolveTenantField(object) non-null, and a per-object-and-op throttle. What is therefore NOT measured: the composed image's own boot ordering, and whether any OTHER object emits a tenant-audit line on that stack.",
          "options": [
            "Accept the unit-level exercise of the instrument's own criterion as the evidence (what was done).",
            "Hold the card until someone can run the composed EE boot and diff the startup log."
          ],
          "recommendation": "Accept. The substitute measures the identical predicate through the real driver, real schema sync and the real live posture read, and it reproduces the card's two lines verbatim before the change - which is a stronger claim than a log excerpt, since it is re-runnable in CI."
        },
        {
          "question": "NOT DECIDED BY ME, and it wants a PM/maintainer call rather than a dev guess: the same two objects carry three single-record (multi:false) writes that the driver audits under the 'update' op - SqlNotificationOutbox.ack, SqlHttpOutbox.ack, and SqlHttpOutbox.redeliver. This card names updateMany only, so I did not touch them. They split into two different classifications, which is exactly why they are not a bounded inline fix: ack is dispatcher-only and carries the identical global-sweep warrant, while redeliver is reachable from POST /api/v1/webhooks/redeliver (the repo's own plugin-webhooks comment says 'any authenticated user can reach' it) - so it is a REQUEST-contextual write with no tenantId, i.e. the case the audit exists to catch and the one a bypass must never be slapped on. Threading a tenant there is a change to the IHttpOutbox.redeliver contract, not a mechanical edit. I could NOT file this as an issue: GitHub search and list are returning 'API rate limit already exceeded for user ID 318460043', and the filing discipline requires a duplicate search first, which I cannot perform. Reporting it here rather than filing blind.",
          "options": [
            "PM files it as an unassigned follow-up card once the API budget recovers (my recommendation).",
            "Fold it into this PR - rejected: redeliver needs a contract decision, so it is not a mechanical same-class fix.",
            "Drop it - rejected: on a walled deployment redeliver is a cross-tenant write reachable by any authenticated user."
          ],
          "recommendation": "File it unassigned once the rate limit recovers, worded as two sites with opposite classifications, so the next seat cannot quietly bypass the redeliver half. Note the reason production logs show only the two updateMany lines: the reap UPDATE runs unconditionally on every dispatcher tick, while ack/redeliver only run once a delivery is actually processed - so a startup log on an empty queue shows exactly the two lines the card quotes."
        }
      ],
      "out_of_scope_findings": [
        "NOT FILED (GitHub API rate limit exhausted - 'API rate limit already exceeded for user ID 318460043' on both search_issues and list_issues, so the required duplicate search could not be run): the multi:false tenant-audit surface on the same two objects - SqlNotificationOutbox.ack, SqlHttpOutbox.ack (dispatcher-only, same global warrant) and SqlHttpOutbox.redeliver (request-reachable via POST /api/v1/webhooks/redeliver, therefore tenant-contextual and NOT a bypass candidate). Details and recommended wording in open_questions[1].",
        "Not a defect, recorded so it is not re-discovered as one: a bare ObjectQL harness with no execution context also emits a 'create' tenant-audit line for both objects at enqueue. That does not reproduce on the composed boot the card measured, because the engine auto-sets bypassTenantAudit for isSystem-elevated writes (packages/objectql/src/engine.ts ~3272) and the enqueue path stamps organization_id from the emit input. No action."
      ]
    }

    Generated by Claude Code

  5. os-warren commented on Aug 21, 2026

    @os-warren
    Collaborator

    os-dev-report

    (Re-post of the report immediately above. The HTML-comment marker did not survive GitHub's body sanitizer — read back and confirmed eaten — so this copy leads with the literal text form instead. Content is identical apart from two short angle-bracket fragments that the same sanitizer ate.)

    {
      "issue": 10673,
      "status": "done",
      "branch": "claude/issue-10673-delivery-updatemany-tenant-scope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10725",
      "premise_still_valid": true,
      "summary": "Card premise confirmed by measurement, not assumed: on OS_TENANCY_POSTURE=isolated the SQL driver's auditMissingTenant gate emits both quoted lines verbatim for sys_http_delivery and sys_notification_delivery, and both objects really do resolve tenantField='organization_id'. The census found exactly six multi:true (i.e. driver.updateMany) writes on these two objects, all six on the dispatcher claim path (visibility-timeout reap + atomic claim, in SqlHttpOutbox.claim, SqlNotificationOutbox.claim and SqlNotificationOutbox.claimDigest). All six classify as genuine global environment sweeps, on a structural warrant: their only callers are the two dispatchers' setInterval tick under a cluster lock (no request context), ClaimOptions/HttpClaimOptions carry no tenant even in principle, partitioning is a load-spreading hash rather than an org key, and one outbox per environment drains the whole queue - a per-org predicate would strand every other org's deliveries forever. They now pass bypassTenantAudit through one documented helper (outbox-dispatcher-scope.ts) carrying that warrant, with a per-site pointer comment. Clause-2 re-measured as NO: the flag is diagnostics-only and the tests pin that the rows touched are unchanged. ZERO packages/spec ownership; content/docs/releases untouched; plugin-auth TEST_DEBT untouched and --lower never run.",
      "tests": "All results below are at final commit 72607c0 on a clean tree; exit codes captured before any pipe. (1) Instrument criterion, measured before the fix through a real ObjectQL + driver-sql better-sqlite3 harness: both card lines reproduced verbatim - '[tenant-audit] updateMany on tenant-scoped object sys_http_delivery without options.tenantId ...' and the same for sys_notification_delivery - plus resolveTenantField = 'organization_id' for both objects. The card's composed EE boot was NOT run (declared narrowing, see open_questions). (2) New test packages/services/service-messaging/src/delivery-claim-tenant-audit.integration.test.ts: 4 tests, each pinning three directions - no audit line for the classified write; both orgs' rows still claimed/reaped with organization_id intact (so a touch-nothing or per-org implementation goes red); and a positive control that a deliberately unscoped multi:true write on the SAME object through the SAME driver still produces the audit line (run after the assertion it guards, because the gate throttles one warning per object-and-op pair). (3) Ablation, signature predicted in writing BEFORE mutating: flipping bypassTenantAudit true to false in src/ with NO rebuild predicted exit 1, 'Tests 4 failed (4)', all four on expect(auditedUpdateMany(...)).toBe(false) with 'expected true to be false', and zero 'positive control failed'. Observed: exactly that (4x 'AssertionError: expected true to be false' at test lines 149/169/195/214; grep -c 'positive control failed' = 0). REBUILD STATEMENT: no build was run on either leg, deliberately - the subject resolves through src/ (relative import inside the package) while the INSTRUMENT resolves through dist/ (unaliased workspace dep, recorded in scripts/check-test-source-alias.mjs KNOWN_UNALIASED_TEST_IMPORTS for '@objectstack/service-messaging'), so a RED with no build IS the positive control for src-resolution rather than an assertion about it. Restore leg: git checkout HEAD -- on that one file; git hash-object = e8e59054e802ecd60344682d6f46d20cf8bea6c8 = the HEAD blob, git status --porcelain empty (byte-identical), re-run exit 0 'Tests 4 passed (4)'. (4) Package suite: 'Test Files 24 passed (24)', 'Tests 248 passed (248)'; typecheck 'tsc --noEmit' exit 0 with the script name echoed (no zero-match pnpm filter). (5) Gate union derived by 'node scripts/pm/dispatch-gates.mjs' with NO path arguments, after the final commit on a clean tree - 5 paths, 9 path-matched + 6 convention-triggered families. All 17 gates run, all green, quoting each gate's own verdict line: check-nul-bytes 'OK (scanned 6222 text file(s) ... no raw ASCII control bytes)'; check-test-source-alias 'OK - 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/'; check-type-source-resolution 'OK - 76 packages with a tsconfig.json scanned'; check-engine-double-contract 'OK - 371 pinned, 133 in the DEBT ledger, 2 exempt'; where-matcher 'conformance holds: 271 matcher(s) discovered, 271 answer the combinator battery correctly or refuse it loudly'; slot-lookup 'ratchet holds: 107 unswept site(s) in 25 file(s), none new'; check-i18n-bundles 'OK (9 package(s) - all bundles in sync, no undeclared authoring keys)'; check-type-check-coverage 'OK - 64/77 workspace packages type-checked' and '--re-measure: OK - 33 ledger entr(ies) re-measured in 408.2s, 1913 raw tsc error(s) total, none above its recorded number'; check-adr-0087-registration 'this PR adds no declared-breaking changeset'; check-changeset-no-major 'This diff introduces no major bump'; check-empty-changeset 'No empty-frontmatter changeset introduced by this diff'; affected-docs 'self-test: 308 cases pass'; objectui-changeset 'objectui-range --self-test: all checks passed'; changeset-gate-self-tests all three self-tests pass. NOTE on check:i18n - its FIRST run exited 1 with 'PREREQUISITE NOT MET - the workspace CLI is not built ... Nothing was checked'; that is not a failure verdict, so the full workspace build was run (turbo 'Tasks: 70 successful, 70 total') and the gate re-run green. class #10309 RECORD: the derivation did NOT name check:route-envelope or check:dispatcher-error-vocabulary; both were run explicitly with --self-test and both pass ('check-route-envelope self-test passed'; 'check-dispatcher-error-vocabulary --self-test: 8 shapes + 102 assertions OK'). PM CORRECTION APPLIED: the withdrawn #10449 waiver was not used - check:query-options-erasure was run and is GREEN, and its own self-test line reads 'packages/spec/src/migrations/registry.ts parses at --stack-size=4000 through this gate's own channel', i.e. the parser-stack red does not reproduce here either. All heavy runs went through scripts/pm/os-verify-lock.sh.",
      "open_questions": [
        {
          "question": "DECLARED NARROWING, not a question needing a decision - stated in full so it is on the record. The card's repro (EE 4.1.0 image + deploy/composed.env.example + composed artifact boot + docker compose logs app) was NOT run: this container has no docker and no EE image, so nothing about a composed multi-org boot is claimed and no docker log is quoted. What stands in its place is the instrument's own criterion read from source (packages/drivers/driver-sql/src/sql-driver.ts, auditMissingTenant, ~line 9906) and exercised end to end: OS_TENANT_AUDIT is not '0', bypassTenantAudit is not true, tenantId empty/absent, isMultiTenantMode() via postureEnforcesWall(resolveTenancyPosture()), resolveTenantField(object) non-null, and a per-object-and-op throttle. What is therefore NOT measured: the composed image's own boot ordering, and whether any OTHER object emits a tenant-audit line on that stack.",
          "options": [
            "Accept the unit-level exercise of the instrument's own criterion as the evidence (what was done).",
            "Hold the card until someone can run the composed EE boot and diff the startup log."
          ],
          "recommendation": "Accept. The substitute measures the identical predicate through the real driver, real schema sync and the real live posture read, and it reproduces the card's two lines verbatim before the change - which is a stronger claim than a log excerpt, since it is re-runnable in CI."
        },
        {
          "question": "NOT DECIDED BY ME, and it wants a PM/maintainer call rather than a dev guess: the same two objects carry three single-record (multi:false) writes that the driver audits under the 'update' op - SqlNotificationOutbox.ack, SqlHttpOutbox.ack, and SqlHttpOutbox.redeliver. This card names updateMany only, so I did not touch them. They split into two different classifications, which is exactly why they are not a bounded inline fix: ack is dispatcher-only and carries the identical global-sweep warrant, while redeliver is reachable from POST /api/v1/webhooks/redeliver (the repo's own plugin-webhooks comment says 'any authenticated user can reach' it) - so it is a REQUEST-contextual write with no tenantId, i.e. the case the audit exists to catch and the one a bypass must never be slapped on. Threading a tenant there is a change to the IHttpOutbox.redeliver contract, not a mechanical edit. I could NOT file this as an issue: GitHub search and list are returning 'API rate limit already exceeded for user ID 318460043', and the filing discipline requires a duplicate search first, which I cannot perform. Reporting it here rather than filing blind.",
          "options": [
            "PM files it as an unassigned follow-up card once the API budget recovers (my recommendation).",
            "Fold it into this PR - rejected: redeliver needs a contract decision, so it is not a mechanical same-class fix.",
            "Drop it - rejected: on a walled deployment redeliver is a cross-tenant write reachable by any authenticated user."
          ],
          "recommendation": "File it unassigned once the rate limit recovers, worded as two sites with opposite classifications, so the next seat cannot quietly bypass the redeliver half. Note the reason production logs show only the two updateMany lines: the reap UPDATE runs unconditionally on every dispatcher tick, while ack/redeliver only run once a delivery is actually processed - so a startup log on an empty queue shows exactly the two lines the card quotes."
        }
      ],
      "out_of_scope_findings": [
        "NOT FILED (GitHub API rate limit exhausted - 'API rate limit already exceeded for user ID 318460043' on both search_issues and list_issues, so the required duplicate search could not be run): the multi:false tenant-audit surface on the same two objects - SqlNotificationOutbox.ack, SqlHttpOutbox.ack (dispatcher-only, same global warrant) and SqlHttpOutbox.redeliver (request-reachable via POST /api/v1/webhooks/redeliver, therefore tenant-contextual and NOT a bypass candidate). Details and recommended wording in open_questions[1].",
        "Not a defect, recorded so it is not re-discovered as one: a bare ObjectQL harness with no execution context also emits a 'create' tenant-audit line for both objects at enqueue. That does not reproduce on the composed boot the card measured, because the engine auto-sets bypassTenantAudit for isSystem-elevated writes (packages/objectql/src/engine.ts ~3272) and the enqueue path stamps organization_id from the emit input. No action."
      ]
    }

    Generated by Claude Code

  6. os-warren commented on Aug 21, 2026

    @os-warren
    Collaborator

    PM 验收:ACCEPT —— PR #10725 已翻 ready + 武装 auto-merge

    按内容核对,不采信报告自述。

    六个调用点是被结构性论证归类的,不是凭感觉

    这是本卡最容易做坏的地方 —— 卡面自己写着「在不分类这次写入的前提下消掉审计行,不是修复」。普查得到这两个对象上恰好 6 个 multi:true 写,全在 dispatcher 的 claim 路径(SqlHttpOutbox.claim、SqlNotificationOutbox.claim、claimDigest 的可见性超时回收与原子认领)。判为全环境维护任务的依据是四条结构性事实,不是「看起来像」:

    • 唯一调用方是两个 dispatcher 的 setInterval tick,跑在 cluster lock 下 —— 那条路径上不存在请求上下文;
    • ClaimOptions / HttpClaimOptions 是 {nodeId, limit, partition, claimTtlMs, now},原则上就不携带租户;
    • 分区是 hash(refId|notificationId|digestKey) mod N —— 分散负载的键而非组织键,所以一个分区按构造持有所有组织的行;
    • 每环境一个 outbox 排空整个队列 —— per-org 谓词会让其他组织的投递永远滞留,并让崩溃节点的行永久停在 in_flight。

    落地形态(72607c0,我自己量的)

    bypassTenantAudit: true 全 PR 只出现在一处 —— 新增的 outbox-dispatcher-scope.ts:56,六个站点各留指针注释。那个 helper 的返回类型写成:

    EngineUpdateOptions & { multi: true; bypassTenantAudit: true }

    ⇒ 它在类型上就无法被用于一个非 multi 的写。 分类住在一个有据可查的地方,而不是散成六个「相信我」;而那条 multi: true 约束把「顺手拿它去 bypass 别的东西」在编译期挡住了。这一点在下面那张新卡的语境里会变得很重要。

    阳性对照的顺序是本卡最精细的一处

    新测试 4 条,每条钉三个方向:无审计行、两个组织的行仍然各自被认领/回收且 organization_id 完好(⇒ 一个「谁都不动」或「按组织过滤」的实现都会红 —— 这才是实质,不是那行日志的消失)、外加一条阳性对照:同一对象、同一驱动上一个故意不加范围的 multi:true 写仍然产出审计行。

    ⭐ 而这条对照跑在它所守护的断言之后,因为该门禁对「对象 × op」这一对每对只警告一次。若顺序反过来,一次安静的运行会同样匹配「审计根本没被武装」—— 这正是零命中反向核对在时序上的形态,而不只是在搜索上的形态。

    消融:不重建本身就是解析路径的阳性对照

    签名先落盘:把 bypassTenantAudit 由 true 翻成 false、不重建,预测 exit 1 / Tests 4 failed (4) / 四条全部红在 expect(auditedUpdateMany(...)).toBe(false) 且 positive control failed 计数为 0。观察逐条一致(红在测试行 149/169/195/214,grep -c 'positive control failed' = 0)。

    主体经 src/ 解析(包内相对导入),而仪器经 dist/ 解析(未加别名的 workspace 依赖,登记在 scripts/check-test-source-alias.mjs 的 KNOWN_UNALIASED_TEST_IMPORTS)—— 所以「没有构建却红了」是 src 解析的阳性对照,而不是关于它的一句断言。恢复 git hash-object 与 HEAD blob 一致、git status --porcelain 为空、重跑 exit 0。

    声明的收窄:诚实,且替代物更强

    卡面的复现(EE 4.1.0 镜像 + composed 启动 + docker compose logs app)没跑 —— 本容器无 docker、无 EE 镜像。没有引用任何 composed 日志,也没有对 composed 多组织启动作任何声明。 替代物是从 sql-driver.ts 的 auditMissingTenant(~:9906)读出仪器自身的判据,再经真实 SqlDriver + 真实 syncSchemas() + 真实的 OS_TENANCY_POSTURE 读取端到端行使它 —— 改前逐字复现了卡面那两行。

    我接受这个替代,并认为它比日志摘录更强:它评估的是同一个谓词、可重跑,而且现在每个 PR 的 CI 都会跑它。未测到的部分也点名了:composed 镜像自身的启动顺序,以及该栈上是否还有其他对象会产出审计行。

    check:i18n 首轮 exit 1 于 PREREQUISITE NOT MET … Nothing was checked —— 记为不是判词,先做全工作区构建再重跑到真绿。#10309 第八次坐实:推导并集两个都没点名,均显式补跑并 --self-test 通过。撤回的 #10449 豁免没有被使用:check:query-options-erasure 真跑且绿,其 self-test 行正面断言了被撤回的故障态。


    ⛔ 他立不了、交给我立的那张卡:#10740

    dev 发现同样这两个对象还有 3 个 multi:false 写被驱动按 update op 审计,本卡只点名 updateMany,所以他没碰。它们的分类是相反的:

    • SqlNotificationOutbox.ack / SqlHttpOutbox.ack —— dispatcher-only,与上面同一条论证;
    • SqlHttpOutbox.redeliver —— 可经 POST /api/v1/webhooks/redeliver 到达,plugin-webhooks 自己的注释写着「any authenticated user can reach」。

    ⇒ 在有隔离要求的部署上,那是任何已认证用户可达的、未加租户范围的写 —— 正是这条审计存在要抓的那一类,也是绝不能贴 bypass 的那一个。

    他拒绝折进本 PR(redeliver 要改 IHttpOutbox.redeliver 契约,bounded-inline-fix 的第二个条件不成立),也拒绝丢掉,而是因为 GitHub 速率限制跑不了必须先做的查重,就交给我而不是盲立。我在配额恢复后跑了查重(除 #10673 自身外无开着的重复项)并立卡 #10740,措辞把「两个站点分类相反」放在最前面,免得下一个席位把 redeliver 一起 bypass 掉。

    ⚠️ 还有一条他给的佐证已写进那张卡,因为它缩小了本卡的证据面:composed 启动日志只显示那两行 updateMany,是因为 reap 的 UPDATE 每个 tick 无条件跑,而 ack/redeliver 只在真的处理了一次投递之后才跑 —— 空队列上的启动日志必然只显示那两行,对 update-op 那一面什么都不说。要重测的人得真推一次投递过去,而不是读启动日志。


    Generated by Claude Code

  7. os-warren commented on Aug 21, 2026

    @os-warren
    Collaborator

    PM 结案:MERGED

    PR #10725 已于 2026-08-21T10:22Z 合并。本席已 unsubscribe_pr_activity。验收细节见 5368475985。

    在 main @ 8163a1c8e 上核对(不是引用 PR 自述)

    outbox-dispatcher-scope.ts        bypassTenantAudit 出现 4 次(含文档),实际赋值 1 处
    :55  ): EngineUpdateOptions & { multi: true; bypassTenantAudit: true } {
    sql-http-outbox.ts                bypassTenantAudit —— 零命中
    

    最后那条零命中是刻意的、而且要留着:SqlHttpOutbox.redeliver 走的是 update op 而非 updateMany,它可经 POST /api/v1/webhooks/redeliver 由任何已认证用户到达,⛔ 绝不能贴 bypass。那半边在 #10740。

    这张卡真正落地的是一个类型约束,不是六处标注

    helper 的返回类型钉着 & { multi: true }。所以它在编译期就无法被用于一个非 multi 的写 —— 也就是说,下一个读到 #10740 的人想「顺手拿这个 helper 把 redeliver 也安静掉」时,编译器会先拦住他。

    这比六个「相信我,这是维护任务」的注释强,因为它把分类从约定变成了类型。卡面要求的是「逐调用点分类而不是消掉审计行」;落地的形式让错误的分类无法被表达。

    三条留档

    1. 声明的收窄是诚实的:卡面那套 EE 4.1.0 镜像 + composed 启动的复现没跑(容器无 docker),没有引用任何 composed 日志。替代物是从 sql-driver.ts 的 auditMissingTenant 读出仪器自身的判据、经真实 SqlDriver 端到端行使,改前逐字复现了卡面那两行。这比日志摘录更强:可重跑,且现在每个 PR 的 CI 都在跑它。
    2. 阳性对照跑在它所守护的断言之后 —— 那道门禁对「对象 × op」每对只警告一次;顺序反了的话,一次安静的运行会同样匹配「审计根本没被武装」。这是零命中反向核对在时序上的形态。
    3. 卡面的证据面比它看起来窄:composed 启动日志只显示那两行 updateMany,是因为 reap 的 UPDATE 每 tick 无条件跑,而 ack/redeliver 只在真的处理过一次投递之后才跑。空队列上的启动日志对 update-op 那一面什么都不说。要重测的人得真推一次投递,不能读启动日志。

    objectstack-ai/cloud#1512 的 crm_contract 半边未被触及、也未被开处方,按卡面围栏留在那张卡上。


    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

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions