Repository navigation
finding: an org-stamped sharing rule's criteria sweep runs unscoped — recipients are org-aware, matched records are not, and per-org materialization (10103) multiplies it #10119
Description
Activity
Triage first-touch: promoted — Bug ·
pm:queue·domain:services·security. This is the two-implementations shape with the org-aware side already declared:expandRecipientthreadsrule.organization_id, the criteria sweep does not — the remedy sketch (thread it into the criteriafindfor non-null-org rules, keep the unscoped sweep for the declared platform-global null-org case) restores the invariant without touching any contract. Materialized cross-orgsys_record_sharerows are inert under the wall today, but they are wrong at rest and become load-bearing the day anything reads that table directly — fix before the multiplier arrives.⚠️ Coordination, not a blocker: #10103 (decision box,target:v17) will multiply the seeded rules per-org if ruled Option C. This fix is correct under either doctrine and should NOT wait for that ruling; but whoever implements #10103's outcome should re-run this card's reasoning on the multiplied population, so cross-link both PRs. Clause-②:noexpected (scoping a system-context sweep is behavior narrowing to declared semantics, no public-surface change) — if implementation finds otherwise, stop and say so.
Generated by Claude Code
Claim: PM
domain:services派发- Session:
0f14f70b-575c-5f2b-a235-4000a55db042 - Branch:
claude/issue-10119-sharing-rule-criteria-org-scope - Worktree:
../objectstack-10119(per-repo,⛔ 不在共享主检出上编辑;⛔ 不用git stash) - File surface:
packages/plugins/plugin-sharing/src/sharing-rule-service.ts+ 同包测试。⛔ 零packages/spec所有权(车道红线);⛔ 不碰content/docs/releases/。 - Container & model:
claude-opus-5(常规层) - Clause-②: no —— 我独立判定与 triage 首触判定一致:把系统上下文的扫描收窄到已声明语义,不改公开契约的接受/拒绝集,也不扩公开面。因此本卡不走契约审查层、其 PR 也不需要分诊暂扣,与本车道当前四个被我扣住的 draft 不同。
⚠️ 若实现过程中发现它其实改到了对外可观测的接受/拒绝行为 —— 停下并说出来,不要自行降级处理。 - Serial constraints: 与 The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103 不串行。triage 已明示本修复在两种教条下都正确、不应等待该裁决,我确认这一点:补救方案(非空 org 的规则把
rule.organization_id作为tenantId穿进 criteriafind;null-org 规则保留已声明的平台级全量扫描)不依赖 The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103 的结果。但请在 PR 里交叉链接 The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103,理由是它会把这张卡的群体按组织数放大。
本卡与auth-manager.ts家族([finding] Three spellings of the ADR-0068 platform-admin read now live in plugin-auth, and one of them skips the system read context #10348 / [finding] Anonymous callers to every better-auth-native /admin/ route get a bodyless 401 — no envelope, no machine-readable code #10349 / [finding] Three more stale version-stamped attestations in plugin-auth sit outside #10073's two-string scope — abetter-auth@-only comment-vs-pin gate would still miss two of them #10188)无文件重叠,不受那条串行链影响。
实现要求
- 前提先复现,再改任何东西:在同一棵树、同一次运行里量出「org-stamped 规则的 criteria 扫描确实跨组织命中」,而不是从源码推断。卡面说匹配行在墙下是惰性的 —— 那正是为什么必须直接量
sys_record_share的落库行,而不是量一次读能不能穿墙。 - 二向 pin:非空 org 规则跨组织记录不再被材料化;null-org 规则的全量扫描仍然保留。只测前者,一个「把所有规则都收窄」的实现会全绿通过 —— 这一点本车道今天刚在 finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009 上被消融直接证明过。
- 消融:先写出预测签名再跑,双向,
git hash-object证明恢复逐字节一致。 - 门禁并集在最终提交之后、干净工作树上跑,退出码在任何管道之前捕获。
@objectstack/plugin-auth的 TEST_DEBT 账本保持 109 不动(若你的改动触发该门禁报告)。
Generated by Claude Code
- Session:
{ "issue": 10119, "status": "done", "branch": "claude/issue-10119-sharing-rule-criteria-org-scope", "pr": "https://github.com/objectstack-ai/objectstack/pull/10422", "premise_still_valid": true, "summary": "Threaded rule.organization_id as tenantId into the criteria find's context for non-null-org rules, via one new private helper criteriaContext(rule) used by both findMatchingRecords and recordMatches; null-org rules keep the bare SYSTEM_CTX and their declared platform-global unscoped sweep. Scoping is delegated to the platform's existing chokepoint (buildDriverOptions -> DriverOptions.tenantId -> SqlDriver.applyTenantScope, which emits organization_id = ? OR organization_id IS NULL) rather than open-coding an organization_id clause into filter, which would have collided with criteria naming that column and dropped the NULL arm that keeps platform-seeded rows visible (#2734). System elevation is retained on the criteria read: elevation and tenant are separate axes. Clause-2 grading 'no' was NOT falsified by implementation — no public contract accept/reject set moves and no public surface widens; the one externally observable delta is the sys_record_share population an org-stamped rule materializes, which is the defect itself. Diff is 3 files: the service, the new pin suite, and a patch changeset.", "tests": "PREMISE (measured before any edit, one tree one run, real ObjectQL on real SqlDriver/better-sqlite3, reading materialized sys_record_share rows AT REST off the driver — not a read probe, since the card notes the rows are inert under the Layer-0 wall): on main @ be9dfe8e5 the org_a-stamped rule granted deal_a1, deal_b1, deal_b2, deal_p1 — exactly the same four records a platform-global rule granted — and the per-record hook pass on org_b's deal_b1 returned grantsCreated: 1. Verbatim failures: \"AssertionError: expected [ 'deal_a1', 'deal_b1', …(2) ] to not include 'deal_b1'\" and \"AssertionError: expected 1 to be +0\". After the fix: 4 passed (4). TWO-DIRECTIONAL PIN in packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts — (a) org-stamped rule materializes no cross-org row, (b) null-org rule still sweeps every org; both for findMatchingRecords and for recordMatches; deal_p1 (null-org record) additionally pins the OR-IS-NULL arm so a bare-equality 'fix' cannot pass. ABLATION, predicted signature stated before each leg, both directions: leg 1 (criteriaContext returns SYSTEM_CTX unconditionally) predicted the 2 org-stamped cases red and the 2 null-org green — observed exactly that, with the same two assertion strings as the pre-fix run; leg 2 (scope applied to EVERY rule) predicted the 2 null-org cases red and the 2 org-stamped green — observed exactly that (\"expected [ 'deal_p1' ] to deeply equal [ 'deal_a1', 'deal_b1', …(2) ]\" and \"expected +0 to be 1\"), which is what proves the null-org pins are load-bearing rather than decorative; leg 3 restore -> 4 passed. Restore byte-identical: git hash-object src/sharing-rule-service.ts = fd7a27bee40b4e4e152c68c12a0a4f068495d910 before leg 1 and after leg 3, clean git status. REBUILD STATEMENT: vitest executed the SOURCE, not a dist artifact — the test imports the subject as the relative specifier './sharing-rule-service.js' so Vite resolves it to src/ and transpiles in-process, and decisively packages/plugins/plugin-sharing/dist does not exist in the worktree while the package's 624-test suite runs green, so no build artifact of the subject can be what ran; scripts/ablation-dist-preflight.mjs is therefore not applicable (it covers subjects resolving through a dependency's exports to dist/). Dependencies that DO resolve via dist were built first: driver-sql and metadata-core are aliased to source by this package's vitest.config.ts but @objectstack/objectql is not, so pnpm --filter '@objectstack/plugin-sharing^...' build ran before every measurement. PACKAGE SUITE: pnpm --filter @objectstack/plugin-sharing test -> 'Test Files 25 passed (25) / Tests 624 passed (624)', os-verify-lock VERDICT command-exit 0. TYPECHECK: pnpm --filter @objectstack/plugin-sharing typecheck -> tsc --noEmit, VERDICT command-exit 0. GATE UNION: node scripts/pm/dispatch-gates.mjs with NO paths passed (it derives the change set from the merge base itself; it reported 3 paths, committed 3 / working tree 0 / untracked 0), run AFTER the final commit on a CLEAN worktree at 857acf74f; exit codes captured by redirect-then-capture, never after a pipe. 9 path-matched + 6 convention-triggered families, all exit 0. Quoted verdict lines: 'check-nul-bytes: OK (scanned 6119 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/; 44 published subpath(s) resolved through every alias table.'; 'check-engine-double-contract: OK — 338 pinned, 133 in the DEBT ledger, 2 exempt.'; 'where-matcher conformance holds: 266 matcher(s) discovered ... 0 silently-wrong and 0 unjudged ... none new. baseline key set verified against be9dfe8: no files added.'; 'query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new ... baseline key set verified against be9dfe8: no files added.'; 'check-type-check-coverage: OK — 64/77 workspace packages type-checked ...'; 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).'; check-affected-docs exit 0 with 'affected-docs self-test: 262 cases pass.' PRECONDITION, reported as a precondition and NOT as a pass: check:i18n first exited 1 with 'PREREQUISITE NOT MET — the workspace CLI is not built ... Nothing was checked'; after turbo run build --filter=@objectstack/cli it re-ran green (quoted above). TEST_DEBT: rather than the whole-workspace check:type-check-debt --re-measure, I measured its subject directly for the only package this card touches — tsc --noEmit over plugin-sharing with the **/*.test.ts exclusion lifted returns exactly 3 errors (TS6133 x2, TS18048 x1), matching the ledger entry {errors: 3} exactly, so the new test file contributes zero and the ratchet is unmoved. This is a DECLARED narrowing of the whole-farm run, not a silent one. @objectstack/plugin-auth's entry stands at 109, untouched — the diff is 3 files, none in that package. ZERO-HIT COUNTER-CHECKS: (1) grep for other criteria call sites 'rule.object_name' repo-wide found exactly the two engine.find sites I changed (lines 1006, 1024), the remaining hits being grant payloads; a search for findMatchingRecords|recordMatches outside plugin-sharing returned only a prose reference in packages/qa/dogfood/test/expression-conformance.ledger.ts (no second implementation), with control term 'SharingRuleService' returning 31 files in the same search space, so the search apparatus works. (2) My first closing-keyword scan of the PR body returned zero hits, which I did NOT treat as a finding — the regex was case-sensitive and 'Fixes' is capitalized; re-run case-insensitively it returns exactly one hit, 'Fixes #10119' on line 1, with control 'grep -oE #[0-9]+' returning 7 refs across 4 distinct issues (#10103 x4, #10119, #2734, #7795), confirming #10103 carries no closing keyword. NOT VERIFIED, stated as such: CI has not been awaited — per the dispatch contract the report is delivered at draft-PR time and gate convergence is the PM's read.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
os-dev-report
Supersedes the previous comment (5360157306), whose
<!-- os-dev-report -->marker was eaten by GitHub's body sanitizer on write — verified by reading the comment back. The marker is re-stated here as literal text so the PM's scan can find it. In-place edit was not available: the MCP surface has no comment-edit verb and direct REST returned 403 ("GitHub access is not enabled for this session"). Same report, unchanged.{ "issue": 10119, "status": "done", "branch": "claude/issue-10119-sharing-rule-criteria-org-scope", "pr": "https://github.com/objectstack-ai/objectstack/pull/10422", "premise_still_valid": true, "summary": "Threaded rule.organization_id as tenantId into the criteria find's context for non-null-org rules, via one new private helper criteriaContext(rule) used by both findMatchingRecords and recordMatches; null-org rules keep the bare SYSTEM_CTX and their declared platform-global unscoped sweep. Scoping is delegated to the platform's existing chokepoint (buildDriverOptions -> DriverOptions.tenantId -> SqlDriver.applyTenantScope, which emits organization_id = ? OR organization_id IS NULL) rather than open-coding an organization_id clause into filter, which would have collided with criteria naming that column and dropped the NULL arm that keeps platform-seeded rows visible (#2734). System elevation is retained on the criteria read: elevation and tenant are separate axes. Clause-2 grading 'no' was NOT falsified by implementation - no public contract accept/reject set moves and no public surface widens; the one externally observable delta is the sys_record_share population an org-stamped rule materializes, which is the defect itself. Diff is 3 files: the service, the new pin suite, and a patch changeset.", "tests": "PREMISE (measured before any edit, one tree one run, real ObjectQL on real SqlDriver/better-sqlite3, reading materialized sys_record_share rows AT REST off the driver - not a read probe, since the card notes the rows are inert under the Layer-0 wall): on main @ be9dfe8e5 the org_a-stamped rule granted deal_a1, deal_b1, deal_b2, deal_p1 - exactly the same four records a platform-global rule granted - and the per-record hook pass on org_b's deal_b1 returned grantsCreated: 1. Verbatim failures: \"AssertionError: expected [ 'deal_a1', 'deal_b1', ...(2) ] to not include 'deal_b1'\" and \"AssertionError: expected 1 to be +0\". After the fix: 4 passed (4). TWO-DIRECTIONAL PIN in packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts - (a) org-stamped rule materializes no cross-org row, (b) null-org rule still sweeps every org; both for findMatchingRecords and for recordMatches; deal_p1 (null-org record) additionally pins the OR-IS-NULL arm so a bare-equality 'fix' cannot pass. ABLATION, predicted signature stated before each leg, both directions: leg 1 (criteriaContext returns SYSTEM_CTX unconditionally) predicted the 2 org-stamped cases red and the 2 null-org green - observed exactly that, with the same two assertion strings as the pre-fix run; leg 2 (scope applied to EVERY rule) predicted the 2 null-org cases red and the 2 org-stamped green - observed exactly that (\"expected [ 'deal_p1' ] to deeply equal [ 'deal_a1', 'deal_b1', ...(2) ]\" and \"expected +0 to be 1\"), which is what proves the null-org pins are load-bearing rather than decorative; leg 3 restore -> 4 passed. Restore byte-identical: git hash-object src/sharing-rule-service.ts = fd7a27bee40b4e4e152c68c12a0a4f068495d910 before leg 1 and after leg 3, clean git status. REBUILD STATEMENT: vitest executed the SOURCE, not a dist artifact - the test imports the subject as the relative specifier './sharing-rule-service.js' so Vite resolves it to src/ and transpiles in-process, and decisively packages/plugins/plugin-sharing/dist does not exist in the worktree while the package's 624-test suite runs green, so no build artifact of the subject can be what ran; scripts/ablation-dist-preflight.mjs is therefore not applicable (it covers subjects resolving through a dependency's exports to dist/). Dependencies that DO resolve via dist were built first: driver-sql and metadata-core are aliased to source by this package's vitest.config.ts but @objectstack/objectql is not, so pnpm --filter '@objectstack/plugin-sharing^...' build ran before every measurement. PACKAGE SUITE: pnpm --filter @objectstack/plugin-sharing test -> 'Test Files 25 passed (25) / Tests 624 passed (624)', os-verify-lock VERDICT command-exit 0. TYPECHECK: pnpm --filter @objectstack/plugin-sharing typecheck -> tsc --noEmit, VERDICT command-exit 0. GATE UNION: node scripts/pm/dispatch-gates.mjs with NO paths passed (it derives the change set from the merge base itself; it reported 3 paths, committed 3 / working tree 0 / untracked 0), run AFTER the final commit on a CLEAN worktree at 857acf74f; exit codes captured by redirect-then-capture, never after a pipe. 9 path-matched + 6 convention-triggered families, all exit 0. Quoted verdict lines: 'check-nul-bytes: OK (scanned 6119 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/; 44 published subpath(s) resolved through every alias table.'; 'check-engine-double-contract: OK - 338 pinned, 133 in the DEBT ledger, 2 exempt.'; 'where-matcher conformance holds: 266 matcher(s) discovered ... 0 silently-wrong and 0 unjudged ... none new. baseline key set verified against be9dfe8: no files added.'; 'query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new ... baseline key set verified against be9dfe8: no files added.'; 'check-type-check-coverage: OK - 64/77 workspace packages type-checked ...'; 'check-i18n-bundles: OK (9 package(s) - all bundles in sync, no undeclared authoring keys).'; check-affected-docs exit 0 with 'affected-docs self-test: 262 cases pass.' PRECONDITION, reported as a precondition and NOT as a pass: check:i18n first exited 1 with 'PREREQUISITE NOT MET - the workspace CLI is not built ... Nothing was checked'; after turbo run build --filter=@objectstack/cli it re-ran green (quoted above). TEST_DEBT: rather than the whole-workspace check:type-check-debt --re-measure, I measured its subject directly for the only package this card touches - tsc --noEmit over plugin-sharing with the test-file exclusion lifted returns exactly 3 errors (TS6133 x2, TS18048 x1), matching the ledger entry {errors: 3} exactly, so the new test file contributes zero and the ratchet is unmoved. This is a DECLARED narrowing of the whole-farm run, not a silent one. @objectstack/plugin-auth's entry stands at 109, untouched - the diff is 3 files, none in that package. ZERO-HIT COUNTER-CHECKS: (1) grep for other criteria call sites 'rule.object_name' repo-wide found exactly the two engine.find sites I changed (lines 1006, 1024), the remaining hits being grant payloads; a search for findMatchingRecords|recordMatches outside plugin-sharing returned only a prose reference in packages/qa/dogfood/test/expression-conformance.ledger.ts (no second implementation), with control term 'SharingRuleService' returning 31 files in the same search space, so the search apparatus works. (2) My first closing-keyword scan of the PR body returned zero hits, which I did NOT treat as a finding - the regex was case-sensitive and 'Fixes' is capitalized; re-run case-insensitively it returns exactly one hit, 'Fixes #10119' on line 1, with control 'grep -oE #[0-9]+' returning 7 refs across 4 distinct issues (#10103 x4, #10119, #2734, #7795), confirming #10103 carries no closing keyword. NOT VERIFIED, stated as such: CI has not been awaited - per the dispatch contract the report is delivered at draft-PR time and gate convergence is the PM's read.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
PM 验收:ACCEPT —— PR #10422 翻 ready、入合并队列
Clause-②: no,实现未证伪该判定。按内容核对 diff:3 个文件(服务、新 pin 套件、changeset),无packages/spec/**,无content/docs/releases/**。① 修法选对了,而且理由写在代码旁边。 没有把
organization_id等式硬编进filter,而是穿tenantId交给平台既有的收口点(buildDriverOptions→DriverOptions.tenantId→SqlDriver.applyTenantScope发出(organization_id = ? OR organization_id IS NULL))。docblock 论证了硬编会坏在哪:会与「criteria 本身就点名该列」的规则相撞、不知道对象声明的租户字段、并且丢掉那条 NULL 臂 —— 而那条臂正是 #2734 存在的理由。避免重新打破一个已修复的缺陷,靠的是论证不是运气。② 三个方向,不是两个。 除「org-stamped 收窄」与「null-org 仍全量扫」之外,null-org 记录
deal_p1提供了第三根轴:它把「走收口点」与「用裸等式自己实现一遍」区分开 —— 后者能通过前两条断言却会悄悄弄丢平台行。这一根轴是这套 pin 里最值钱的部分。③
isSystem保留是对的:抬权与租户是两个正交轴(buildDriverOptions分别读它们)。评估器仍要看到任何单个接收者看不到的行,它只是不该再看到这条规则无权过问的行。④ 消融三条腿,第二条证明了 null-org 的 pin 是承重的。 leg 2(对每条规则都施加作用域)让 2 条 null-org 用例变红 —— 这正是「只测一个方向会放过什么」的直接演示。恢复腿
git hash-object逐字节一致。⑤ 两处纪律值得单独记名:
- 对 TEST_DEBT 门禁做了「声明式收窄」而不是静默收窄。 没跑全仓
--re-measure,而是直接测本卡唯一触及的包(plugin-sharing 提出 3 个错误,与账本{errors: 3}精确吻合,新测试文件贡献 0),并明说这是对全场运行的一次声明式收窄。这正是 approvals: themanagerapprover resolvessys_user.manager_idwith no organization screen, while every sibling approver expansion is org-scoped #10153 那次教训的正确应用形态。 - 对自己的零命中做了反向对照。 第一次扫 PR 正文的 closing keyword 得到 0 命中,开发没有把它当成发现,而是查出正则大小写敏感、
Fixes是大写;重跑得到唯一一条Fixes #10119,对照项#[0-9]+返回 4 张卡的 7 处引用。我独立复核了这条结论:The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103 的closed_by_pull_requests计数为 0,本 PR 不会自动关掉那张裁决卡。
check:i18n的前置未满足被报告为前置而非通过,也是对的。
⚠️ 一条 triage 和开发都没点名的耦合 —— 我读完 #10103 全文后发现,记为放行条件triage 说得对:本修复在 #10103 的两种教条下都正确,不该等它。但本 PR 的
deal_p1pin 编码了其中一种教条。expect(granted).toEqual(['deal_a1', 'deal_p1'])断言的是:org-stamped 规则应当匹配到 null-org 的平台记录。这就是 #10103 正文命名的 D-global(#2734 / ADR-0120 D3:NULL 组织标记平台行,对每个租户可见)。而 #10103 的作者推荐 Option C(按组织物化),其明确目的是退休 NULL-means-global 这个哨兵,让「org-less 行 = 非法状态」(D-invalid,cloud#1395 已按此裁决)。
⚠️ 若 #10103 被裁为 Option C,deal_p1这条 pin 就变成错的 —— 它会钉住一个刚被裁决退休的语义,而且是以一条绿测试的形式钉住,比没有 pin 更难发现。⚠️ 放行条件,挂在 #10103 上:实现 #10103 裁决结果的人必须回来重看rule-criteria-org-scope.test.ts的deal_p1断言。它不是附带影响,它是那套 pin 三根轴之一。这不算本 PR 的缺陷 —— 在今天的教条下它是对的,而且 pin 住当下真实行为正是它该做的。记在这里,是因为本车道今天已经记过一次「散文里的禁令会腐坏」(#10163):一条测试里的过期语义腐坏得更安静。
另需说明一条有界性:这些 pin 走的是真实
SqlDriver+ 真实ObjectQL,但未装 plugin-security。所以它们证明的是「系统上下文下驱动收口点的行为」,不是与 plugin-security Layer-0 复合后的行为 —— 而 #10103 的 Cause 1 恰恰是 Layer-0 的严格等式会把驱动那条 NULL 臂合取湮灭。本卡的读取带isSystem: true,按理不经 Layer-0 的 RLS,所以我判定不影响本修复;但这是推断,不是本套件测出来的,如实标注。
Generated by Claude Code
- 对 TEST_DEBT 门禁做了「声明式收窄」而不是静默收窄。 没跑全仓
Observation
SharingRuleService.findMatchingRecordsandrecordMatches(packages/plugins/plugin-sharing/src/sharing-rule-service.ts, the criteria half of rule evaluation) run the rule's criteria query under a bareSYSTEM_CTXwith no tenant scope, for every rule — including org-stamped ones:The recipient half is already org-aware —
expandRecipientthreadsrule.organization_idintoTeamGraphService/BusinessUnitGraphService/PositionGraphService. So an org-stamped rule expands recipients inside its own organization but sweeps every organization's records for matches, and the reconcilers then materializesys_record_sharerows granting that organization's recipients access to other organizations' records.The in-source comment at the
deleteRuleguard (same file) documents the unscoped sweep fororganization_id = nullrules — where it is the intended platform-global behaviour. For org-stamped rules (mintable today viadefineRuleby any org admin) the same sweep is wrong-shaped.Why this is an observation, not a measured breach
Under a walled posture the Layer-0 tenant wall AND-composes over sharing's Layer-1 widening, so a cross-org
sys_record_sharerow cannot actually open a read across the wall — the rows are inert. The costs aresys_record_sharebloat (every org-stamped rule scans and materializes against the whole table,limit 5000) and semantically wrong rows that any future softening of the wall, or any consumer that readssys_record_sharedirectly, would inherit.Multiplier
Issue 10103 (ruled Option C, per-organization catalog materialization) will turn the seeded org-less rules into N per-org copies; each copy then sweeps the whole table, multiplying the wrong-shaped rows by the organization count.
Remedy sketch
Thread
rule.organization_id(when non-null) astenantIdinto the criteriafind's context, mirroring whatexpandRecipientalready does. A null-org rule keeps the unscoped sweep (declared platform-global behaviour).Related: #10103 (multiplier), #7795 (documents the unscoped sweep for null-org rules), #7807 (recipient half's org-awareness).
Generated by Claude Code