fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547) - #14572

Closed
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen
Closed

fix(plugin-sharing): a seeded business unit is a usable rule recipient, and its members are tenant-screened (#14547)#14572
baozhoutao wants to merge 2 commits into
mainfrom
claude/issue-14547-bu-graph-tenant-screen

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #14547

A sharing rule with a business-unit recipient granted nobody — silently — whenever the unit came from seed data. BusinessUnitGraphService.orgScope screened sys_business_unit with a strict organization_id equality, while the platform's own read-side tenant screen (SqlDriver.applyTenantScope) is null-inclusive: (organization_id = ? OR organization_id IS NULL). A rule always carries the caller's organization; a seeded unit carries none, because a seed cannot know the id the runtime mints at boot. The two never matched, seedIsUsable read the unit as "does not exist", both recipient widths returned zero users, and the rule stayed active: true having materialised no sys_record_share row and logged nothing.

This lands recommendation A from the triage — both halves in one PR, because the first alone is a leak.

1. The unit screen is now the platform's own null-inclusive one

orgScope composes $or: [{ organization_id }, { organization_id: null }], matching applyTenantScope and the #2734 rationale, and matching the reading plugin-approvals already had for the same rows (#3807). The [divergence] pin that recorded the old posture as deliberate is flipped in place rather than deleted.

Spelled as a predicate rather than as context.tenantId, for two reasons: the graph's reads are elevated precisely so they can see rows no recipient could (elevation and tenant are separate axes in buildDriverOptions), and driver-memory / driver-mongodb implement no tenant scoping at all, so a screen living only inside the SQL family would be no screen.

2. The member reads are screened at all — they were not before

Both expandUnitMembers and expandUsers queried sys_business_unit_member with no organization predicate, under a system context that carries no tenant. That was invisible only because the strict unit screen kept an org-stamped rule from ever reaching the unscoped query. Widening the unit screen alone turns a silent under-grant into a silent cross-tenant over-grant: a seeded unit id exists identically in every tenant, so tenant A's rule would have expanded to tenant B's members.

Both widths are screened, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported reproduction used, and its member read carried the identical hole. The PR names it here rather than landing it quietly.

3. An active business-unit rule that expands to nobody is loud

SharingRuleService.expandRecipient warns once per rule per process — the same dedup the inert-criteria warn carries, for the same reason — naming the rule, the object, the recipient kind, the unit and the organization, and pointing at the two causes worth checking first. Scoped to the two business-unit recipient kinds deliberately: queue expands to [] by construction today, so a blanket "any empty expansion" warn would fire on every pass of every queue rule.

Prerequisite measurement: are membership rows organization-stamped on every write path?

No. This is what decides the shape of change 2, so it is stated in full.

write pathstamps organization_id?why
REST / session writeYESthe engine threads execCtx.tenantId, and the SQL driver's injectTenantOnInsert fills the injected column
seed replayNOseed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object
elevated (system-context) writeNOsys_business_unit_member is unclassified in PLATFORM_OBJECT_TENANCY, so resolveSystemInsertOrganization returns early
driver-memory / driver-mongodbNOneither implements a tenant column at all

So the member screen is strict, not null-inclusive, and the asymmetry with the unit screen is the point. On a unit row a NULL organization is the documented platform/seeded class. On a membership row it is unknown tenancy — and admitting an identity of unknown tenancy into an org-stamped grant is the same cross-tenant over-grant arriving by the other door. A grant fails closed.

The declared cost: a rule carrying an organization whose unit AND memberships were both seeded still expands to nobody. That combination is exactly what change 3 makes loud, and the repair is to stamp the membership rows. The underlying gap — sys_business_unit_member being unadjudicated in the tenancy ledger — is filed separately as #14570 (unassigned, for the adjudication batch the ledger's header describes); it is not addressed here.

The dominant path today is unmoved: with no organization on the rule there is nothing to screen against and both reads stay exactly as they were. That is what every materialised rule on a showcase stack looks like — defineRule reads the caller's organization, and a bare isSystem context has none — which is why the dogfood BU-hierarchy fixture is byte-identically unaffected.

Fixture triage

Three existing fixtures paired org-scoped units with org-less membership rows and passed anyway — because the member read had no predicate. Each was re-stamped rather than worked around, and each carries a comment saying which of the two facts it was always describing. One pin ([divergence]) records a retired posture and is flipped; the head docblock that nominated this exact predicate as the future fix is rewritten to describe the pair of screens that landed.

Verification

All commands run at c079c35.

  • pnpm --filter @objectstack/plugin-sharing test31 files / 731 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck — green, including check:test-typecheck, which does cover the new test file (the package's tsconfig.json excludes *.test.ts, its tsconfig.test.json does not; the gate named the new file's one type error before it was fixed, which is the proof it reads it).
  • Gate union: 68 families, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, re-run at c079c35 after the last commit. All green except check-test-completeness, which exits 3 = NOT MEASURED by its own design when handed no saved turbo run test log — not a red.
    • check:system-context-census needed a --fix: inserting 7 lines into sharing-rule-service.ts rotted two anchors (:157 to :164, :382 to :389) in content/docs/permissions/system-context.mdx. Pure line rot, arithmetic confirms it.
    • check:engine-double-contract needed a --write: the new test file pins engine doubles the ledger did not record.
  • pnpm lint — the whole repo, eslint . --no-inline-config, green in 86s. No narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan over every file in the diff: no hits.

Reverse verification (two ablations)

No rebuild is involved: the tests import ./business-unit-graph.js relative, inside the package, so vitest resolves source and a dist/ cannot mask the mutation. Each leg proved the mutation on disk by grep counts of both the injected marker and the removed text (not by the editor's exit code), and by a git hash-object differing from the HEAD blob; each restored with git checkout HEAD -- ABSOLUTE_PATH under an EXIT INT TERM trap and was proved restored by an empty git diff HEAD plus a blob hash equal to HEAD's.

ablationexpectedobserved
orgScope back to strict equalityred on the seeded-unit pins8 failed / 22 passed — including (a) an org-NULL unit is USABLE, the flipped divergence pin, and both end-to-end materialisation cases
both memberScope calls removedred on the tenancy pins6 failed / 24 passed — including (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn

Neither ablation is a subset of the other, which is what shows the two screens are two facts rather than one.

Not run locally

The dogfood suite (a booted stack) and the full cross-package test farm are CI's. The argument above — every materialised rule on that stack carries a null organization, so both screens are no-ops there — is reasoning, not a measurement, and the Dogfood Regression Gate is where it gets checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb

Generated by Claude Code


Generated by Claude Code

…recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…er screen; ledger + census upkeep (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 10 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via expandRecipient (symbol, a method of class SharingRuleService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86129a2ff2cec18eed68a7cab6b928b2e5238e7a — the merge of head c079c35cea83cb342bccfeb96e8a567cebaf55f5 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86129a2ff2cec18eed68a7cab6b928b2e5238e7a && git checkout 86129a2ff2cec18eed68a7cab6b928b2e5238e7a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad c079c35cea83cb342bccfeb96e8a567cebaf55f5 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff c079c35cea83cb342bccfeb96e8a567cebaf55f5
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@baozhoutaoClaude

Copy link
Copy Markdown
ContributorAuthor

Closing this draft without merging, on the maintainer's instruction (2026-09-03): the kpi project only reports platform issues and does not fix them in the platform repository. The defect report stays open as #14547 (with #14570 for the residual gap); the branch is left as a reference for whoever picks the issue up. Nothing on main was touched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@claude