Repository navigation
[finding] sys_inbox_message/sys_notification/sys_email 等平台表从不写 organization_id(存量与新增行 100% null)——请确认多组织语义是否设计如此 #11303
Description
Activity
分诊落箱(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
Maintainer ruling recorded — gap, not design: these tables must write
organization_idProvenance: 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:
- Not by design — a gap.
sys_inbox_message,sys_notification(+ receipts, deliveries) andsys_emailmust stamporganization_idon write, same assys_approval_requestalready does. The producers (notification outbox/emit paths, email send path in plugin-email/services) are the fix site. - Visibility boundary follows org isolation once stamped. The
sys_audit_log/sys_activitymixed state (34%/26% null) is the same gap on those writers: new rows get stamped going forward. - 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
- Not by design — a gap.
{ "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
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).
- Decision 1 —
sys_audit_log/sys_activityresidual 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-pinnedtenancy.organizationFieldkey (spec: audit stamping needs a read-neutral organization declaration —tenancy.tenantFieldcannot servesys_api_keywithout 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. - Decision 2 —
sys_email: separate card, ruled to file. WideningSendEmailInputinpackages/spec/src/contracts/email-service.tsis Clause-② work with spec ownership: thread from callers that hold an organization (the email channel'sdelivery.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. - 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:queuein the same stroke for that one remaining action.
Generated by Claude Code
- Decision 1 —
- added a commit that references this issue
on Aug 24, 2026 - added a commit that references this issue
on Aug 31, 2026
一句话(人话摘要)
应用项目实测发现:
sys_inbox_message/sys_notification(及回执、投递)/sys_email等平台表从不写organization_id——存量与当天新增行 100% 为 null。请平台确认这是设计如此(这些对象按用户域而非组织域)还是缺口;若是设计如此,多组织隔离语义如何界定。项目侧按约定不动这些表的数据,只报备等口径。环境
@objectstack/*@17.0.0(GA,锁版);数据库 PostgreSQL 16(容器部署)sys_organization现有 1 个组织实测观察(2026-08-23,只读 SQL 盘点)
sys_inbox_messagesys_notification_receiptsys_notificationsys_notification_deliverysys_emailsys_audit_logsys_activity关键佐证:
sys_inbox_message按日分布,2026-08-23 当天新增 3 行全部 null——不是存量残留,是持续行为;sys_approval_request(32 行)/sys_approval_approver(1 行)organization_id均有值、0 null——审批对象有组织戳而消息/邮件对象恒无,疑似口径不一;sys_*似豁免于该约束。想请平台回答的问题
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 行)?由来
应用项目升级门禁盘点(项目侧 issue steedos-labs/os-project-titanwind-ehr#1750)中顺带发现;业务表的存量 null 组织行(853 行)已在项目侧按批复回填完毕,与本单无关,本单只涉及
sys_*平台表。