Skip to content

[finding] sys_inbox_message/sys_notification/sys_email 等平台表从不写 organization_id(存量与新增行 100% null)——请确认多组织语义是否设计如此 #11303

Description

@baozhoutao

一句话(人话摘要)

应用项目实测发现:sys_inbox_message / sys_notification(及回执、投递)/ sys_email 等平台表从不写 organization_id——存量与当天新增行 100% 为 null。请平台确认这是设计如此(这些对象按用户域而非组织域)还是缺口;若是设计如此,多组织隔离语义如何界定。项目侧按约定不动这些表的数据,只报备等口径。

环境

  • 平台:@objectstack/*@17.0.0(GA,锁版);数据库 PostgreSQL 16(容器部署)
  • 项目:os-project-titanwind-ehr 试运行实例;sys_organization 现有 1 个组织

实测观察(2026-08-23,只读 SQL 盘点)

表 总行数 organization_id 为 null
sys_inbox_message 383 383(100%)
sys_notification_receipt 383 383(100%)
sys_notification 205 205(100%)
sys_notification_delivery 205 205(100%)
sys_email 96 96(100%)
sys_audit_log 4485 1510(34%)
sys_activity 3469 916(26%)

关键佐证:

  1. 新增行同样不带:sys_inbox_message 按日分布,2026-08-23 当天新增 3 行全部 null——不是存量残留,是持续行为;
  2. 对照组:同库 sys_approval_request(32 行)/ sys_approval_approver(1 行)organization_id 均有值、0 null——审批对象有组织戳而消息/邮件对象恒无,疑似口径不一;
  3. 业务对象侧(应用命名空间各表)在组织上下文修复后新增行均正确带组织,17.1.0 起无组织写会被拒——sys_* 似豁免于该约束。

想请平台回答的问题

  1. 上述消息/邮件/日志类平台对象不写组织是否设计如此(按 user 域路由,不参与组织隔离)?
  2. 若是设计:多组织实例下,站内信/通知中心的可见性边界以什么为准?sys_audit_log/sys_activity 的部分行有组织、部分为 null(34%/26%),这种混合态是预期吗(参考:audit: a user's FIRST session predates their membership, so every audit row written in that window carries a NULL tenant and is invisible to RLS readers #8245 处理过首会话窗口的 NULL tenant audit 行)?
  3. 若属缺口:应用项目是否应回填?我们默认不碰平台表数据,等平台给口径或修复。

由来

应用项目升级门禁盘点(项目侧 issue steedos-labs/os-project-titanwind-ehr#1750)中顺带发现;业务表的存量 null 组织行(853 行)已在项目侧按批复回填完毕,与本单无关,本单只涉及 sys_* 平台表。

Activity

  1. claude commented on Aug 24, 2026

    @claude
    Contributor

    分诊落箱(session session_01Kktexqp6uVuFMztvvTMf3V,2026-08-24)。一句话问题:多组织部署里,站内信、通知、邮件这些平台表的每一行都不属于任何组织(实测 100% null)—— 客户项目在等平台一句口径:这是设计,还是缺口。

    选项 × 真实代价:

    • A. 确认「收件人域」设计:站内信/通知/回执/邮件按用户隔离(收件人就是边界),不按组织;把口径写进对象文档/liveness 记录。代价:一次文档写作;业务上 = 像多数 SaaS 的通知中心 —— 通知跟人走,不跟组织走,跨组织的用户在一个收件箱里看到自己的全部通知。
    • B. 判定缺口:通知/邮件应带 organization_id,立修复卡(存量回填另案四棱)。代价:写路径全面改造 + 存量迁移;业务上 = 组织切换时收件箱按组织过滤,安全评审好答但工程大。
    • C. defer:不答。业务上 = 报告方(真实医院试运行项目)的安全评审悬着,项目侧不敢动数据。

    四棱:

    • ① 长远合理性:收件人域是站得住的长期口径(行是「发给某人的」,组织只是发生地);但 sys_audit_log(34% 带组织)与 sys_activity(26%)的混合态两个口径都解释不了,那才是真正的债。
    • ② 实际业务拉动:真实部署实测上报、项目在等口径 —— 拉动为实;报告方只求口径不求改造。
    • ③ 防 AI 犯错:最危险的是「有的表带有的表不带」—— AI 写报表/清理脚本时按组织过滤会静默漏数据。口径落文档后,AI 至少有权威可查。
    • ④ 创业阶段不扩散:A 是零工程收口;B 是为「今天没人要求的隔离语义」预付全面改造 —— 无拉动不扩散。

    推荐 A(对 inbox/notification/email/receipt/delivery 五表),并把 audit_log/activity 的混合态单列为后续缺陷卡(谁在写带组织的 1/3、谁在写不带的 2/3 —— 生产者普查);回退 C。置信缺口:本分析未核 RLS/共享读路径是否已有按组织过滤这些表的谓词(若有,A 与现行读路径矛盾,需先测)。回「A」/「B」即可;「A,混合态不立卡」也可。裁后执行:A ⇒ 口径文档卡入 services 队列 + audit/activity 生产者普查卡(finding);并回帖告知报告方口径。


    Generated by Claude Code

  2. added theissue type on Aug 24, 2026
  3. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — gap, not design: these tables must write organization_id

    Provenance: maintainer, 2026-08-24, live PM chat, verbatim: 「11303 sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」

    Answers to the reporter's three questions:

    1. Not by design — a gap. sys_inbox_message, sys_notification (+ receipts, deliveries) and sys_email must stamp organization_id on write, same as sys_approval_request already does. The producers (notification outbox/emit paths, email send path in plugin-email/services) are the fix site.
    2. Visibility boundary follows org isolation once stamped. The sys_audit_log / sys_activity mixed state (34%/26% null) is the same gap on those writers: new rows get stamped going forward.
    3. Backfill: the app project should NOT touch platform tables. Default posture for existing rows follows the maintainer's standing 2026-08-23 precedent on backfills (「10950 不考虑存量」): no retroactive backfill unless the maintainer separately orders one; the fix is forward-stamping.

    State: needs-user-decision → pm:queue (services lane), type Bug. Dispatch notes: multi-tenancy/security-adjacent — the writers are engine-context emit paths (ADR-0103 notes receipts are context-less bare engine writes — the fix must thread org context to them, not fabricate it); pin per-producer (each writer stamps, and a walled fresh boot writes zero org-less rows into these tables). Reporter (titanwind-ehr project) can be pointed at this ruling as the official口径.


    Generated by Claude Code

  4. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11303,
      "status": "needs_decision",
      "branch": "claude/issue-11303-stamp-organization-id-on-platform-writes",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11698",
      "premise_still_valid": true,
      "summary": "Premise holds for the notification family and is REFINED for the audit family. Producer census: the messaging chain below emit() was ALREADY threaded end to end (writeEvent stamps sys_notification, the inbox channel stamps sys_inbox_message and its delivered receipt, the outbox carries it onto sys_notification_delivery) -- all reading EmitInput.organizationId. The break was at the ORIGIN: notify-node.ts never passed organizationId and its local structural mirror of emit() did not even declare the field, so one missing argument put four tables at 100% null. A SECOND producer of the same table was found: MessagingService.markRead inserts the 'read' receipt with no organization at all. Both are fixed by threading -- notify from AutomationContext.tenantId (the same source plugin-audit's collab.mention producer already uses), markRead from the sys_notification row the receipt is about (#8287 subject-record precedence). NO fallback limb in either: where no organization is in scope, none is written. TWO PARTS OF THE RULED SURFACE ARE NOT DELIVERED, see open_questions. (a) sys_email hits the packages/spec fence: threading it requires widening SendEmailInput in packages/spec/src/contracts/email-service.ts; plugin-email has zero organization handling today. Stopped and reporting rather than guessing at a public contract. (b) sys_audit_log / sys_activity: the card's premise does NOT hold as written -- all three writers (CRUD, auth-event, read-audit) already stamp organization_id via the shared platform-row resolver (#8707/#10101); residual nulls are writes with no resolvable organization, exactly the case the ruling forbids filling by fabrication. No change needed, none made -- so this PR touches no sys_audit_log declaration or writer and does NOT overlap #11676. PR body states plainly what is not covered so the partial cannot read as complete; card should stay open (Part of #11303, not Fixes).",
      "tests": "All measurements on 8321b6a8, clean tree at the final commit. TEST-FIRST, no ablation and no restore anywhere, so no restore could silently fail: every pin was written and run RED on an otherwise-unmodified tree (git status showed only the new test file; git diff vs BASE 8bcd0547 empty) with its signature predicted in writing first. Predictions vs observed, all matched: PIN A 'expected undefined to be org_pin_alpha'; PIN B \"expected [ 'sys_notification:NULL', ...(2) ] to deeply equal [ ...(3) ]\"; PIN B2 'expected undefined to be org_pin_alpha'; PIN D no organization warning; PIN E 'expected undefined to be org_pin_beta'. Over-denial controls PIN C and PIN E2 were GREEN before and after (a stack with no organization still delivers and still writes) -- that is required pin 4. PIN B asserts an IDENTITY list (object:organization_id per row, in write order), not a count. Required pin 3 answered by WARN, not refusal: refusing would break single-posture installs and every stack before its first organization exists, which PIN C is there to catch. GREEN after the fix, each suite's own totals: service-messaging 'Test Files 27 passed (27)' / 'Tests 276 passed (276)'; service-automation 'Test Files 87 passed (87)' / 'Tests 1036 passed (1036)'; service-messaging typecheck clean. Same file/test totals as the red run (87 files, 1036 tests) so no test was lost. Gates derived with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack', no path args, clean tree, after the final commit; provenance line confirmed 'derived from the tree of objectstack-ai/objectstack at commit ... --repo ... it holds'. Every exit code captured before any pipe (each gate redirected to its own log, EXIT read directly). All green, quoting each gate's OWN verdict line: 'check-engine-double-contract: OK -- 398 pinned, 133 in the DEBT ledger, 2 exempt.'; 'where-matcher conformance holds: 293 matcher(s) discovered ... none new.' + 'baseline key set verified against 8bcd054: no files added.'; 'query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new' + 'baseline key set verified against 8bcd054: no files added.'; 'check-type-check-coverage: OK -- 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger, 1 exempt.'; 'check-nul-bytes: OK (scanned 6524 text file(s) ... no raw ASCII control bytes).'; 'check-i18n-bundles: OK (9 package(s) -- all bundles in sync)' with 'services/service-messaging in sync (4 bundle(s))'. Also green: changeset-gate-self-tests, cross-package-test-inputs, objectui-changeset, published-files, slot-lookup, test-source-alias, type-source-resolution, adr-0087-registration, changeset-no-major, empty-changeset, plugin-teardown-shape, affected-docs, release-rehearsal-clone --self-test. NEITHER RATCHET MOVED. Two NOT-MEASURED results handled as such, never as passes: (1) the first dependency build returned exit 143 after burning 8 of its 10 foreground minutes waiting on the shared lock -- rerun split and green; (2) check:i18n first returned 'PREREQUISITE NOT MET ... Nothing was checked' (unbuilt CLI) -- built @objectstack/cli and reran to the OK line quoted above. DECLARED NARROWING: check:type-check-debt not run locally -- it demands a full workspace turbo build before it will measure and refuses otherwise, and a refusal is NOT MEASURED, so it is named as not-run rather than reported green; repo-wide 'pnpm lint' likewise left to CI. Both new test doubles declare no update()/delete() verb, so they add no engine-double pin and leave the coverage ledger untouched. Mutations to source were confirmed on disk by anchored grep in BOTH directions (injected text present AND the pre-edit shape absent), with python replacement counts asserted == 1 per anchor -- not by an editor exit code.",
      "open_questions": [
        {
          "question": "sys_email: threading an organization to the sys_email writer requires adding organizationId to SendEmailInput, which lives in packages/spec/src/contracts/email-service.ts -- a packages/spec edit this card has zero ownership of. plugin-email has no organization handling at all today (grep finds zero matches across email-service.ts, email-plugin.ts, sys-email-payload.ts), and the writer runs under a constant SYSTEM_CTX with no execution context to read. How should the sys_email half proceed?",
          "options": [
            "A -- Widen the spec contract: add optional organizationId to SendEmailInput, thread it from the callers that HAVE an organization (notably service-messaging's email channel, which already holds delivery.notification.organizationId), and leave it absent where the caller genuinely has none (auth verification/reset mail). Needs a separate card with packages/spec ownership; it is contract-first and would be Clause-2 by the public-surface test.",
            "B -- Resolve the organization inside plugin-email's persistence adapter. REJECT: there is no context there to read, so any value would be fabricated -- the exact failure the ruling forbids.",
            "C -- Declare sys_email deliberately org-less on the object (tenancy: { enabled: false }, ADR-0066), making the null a stated fact rather than a silent gap. Contradicts the ruling's literal text, so it needs the maintainer.",
            "D -- Defer sys_email entirely and close it out of #11303 as a separate ruled item."
          ],
          "recommendation": "A, as a separate card with packages/spec ownership. It is the only option that threads rather than fabricates, and the email channel already holds the value, so the producer-side work is small once the contract allows it. It must NOT be smuggled into this PR: it changes a public contract surface, which reclassifies the card to Clause-2 and changes its landing path -- flagging that per the dispatch's own instruction."
        },
        {
          "question": "sys_audit_log / sys_activity: the ruling says 'same gap on those writers', but measurement contradicts it -- all three writers already stamp organization_id through the sanctioned shared resolver. The residual 34%/26% nulls are writes where NO organization was resolvable (system context, no session organization, or a record with no organization column). Closing those needs a decision.",
          "options": [
            "A -- Accept the current behaviour: the writers thread correctly and a null means 'genuinely no organization was in scope'. Nothing to fix; record the finding against the card.",
            "B -- Derive the install's organization on the single-tenant branch, reusing the #8844 machinery already in packages/objectql/src/tenancy/system-write-organization.ts (single posture + exactly one organization => derive; walled posture => refuse loudly). Not fabrication -- it is the platform's own existing ruled derivation -- but it is a separate, larger design with its own blast radius.",
            "C -- Fabricate a fallback (current organization / first organization). REJECT: forbidden by this card's own ruling."
          ],
          "recommendation": "A for this card, with B filed separately if the maintainer wants the residual nulls closed. Note that B would newly make the notification and audit writers consumers of the #8844 derivation, and separately that the shared record-organization resolver's key (tenancy.organizationField) is SCOPE-PINNED to exactly three sanctioned consumers (#8778 / cloud#1395) -- a fourth needs its own maintainer ruling. I deliberately did NOT make the notify producer a fourth consumer for that reason; it uses only the acting context's tenant."
        },
        {
          "question": "Fail-loud shape: required pin 3 asks for refuse-or-warn. I implemented WARN (naming the topic and the consequence) rather than refusal. Confirm this is the wanted posture.",
          "options": [
            "A -- Warn, do not refuse (implemented).",
            "B -- Refuse when no organization resolves."
          ],
          "recommendation": "A. B breaks the two deployments that legitimately have no organization to thread -- a single-posture install, and every stack before its first organization exists -- which is exactly what required pin 4's over-denial control (PIN C) exists to catch. This mirrors the platform's own precedent: system-write-organization.ts refuses only under a WALLED posture and explicitly never on the no-organization-yet branch, because 'refusing here would refuse first boot itself'."
        }
      ],
      "out_of_scope_findings": []
    }
    

    Generated by Claude Code

  5. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — all three sub-decisions

    Ruled 2026-08-24, live PM chat, verbatim: 「其他按照你的建议继续」 (adopting the skills seat's four-facet digest on this card's three open questions).

    1. Decision 1 — sys_audit_log / sys_activity residual nulls: A. The three writers already stamp correctly through the shared resolver; a null now means "genuinely no organization was in scope", and that semantics is accepted. B is NOT taken — extending the System-context writes land untenanted at RUNTIME, so a single-tenant install keeps re-forking the autonumber scope and minting duplicate business identifiers — the producer #8686's backfill cannot reach (17.0.0 GA) #8844 derivation would make the notification/audit writers a fourth consumer of the scope-pinned tenancy.organizationField key (spec: audit stamping needs a read-neutral organization declaration — tenancy.tenantField cannot serve sys_api_key without walling the credential table (#8707 remainder) #8778 / cloud#1395), which is its own design decision to be pulled by a measured reader need, not absorbed here.
    2. Decision 2 — sys_email: separate card, ruled to file. Widening SendEmailInput in packages/spec/src/contracts/email-service.ts is Clause-② work with spec ownership: thread from callers that hold an organization (the email channel's delivery.notification.organizationId), absent stays legal where the caller genuinely has none (auth verification/reset mail). The services seat files it with this provenance; ⛔ never smuggled into the merged PR fix(messaging): stamp organization_id on flow-produced notifications and markRead receipts #11698.
    3. Decision 3 — warn, not refuse: CONFIRMED. Refusing would refuse single-posture installs and every first boot, exactly as system-write-organization.ts's own precedent states.

    With the partial delivered (PR #11698 merged at ffbb7a10), this card closes once the Decision-2 spec card exists and is linked here. needs-user-decision → pm:queue in the same stroke for that one remaining action.


    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