fix(plugin-approvals): screen expanded business-unit members to the directory organization (#14946) - #15912
Conversation
…) — red against the unmodified service Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…irectory organization (#14946) `expandBusinessUnitUsers` screened the unit rows with the null-inclusive `businessUnitOrgScope` (#3807) but read `sys_business_unit_member` with no organization predicate, under SYSTEM_CTX which carries no tenant. A seeded unit id exists in every tenant, so tenant A's department approver resolved tenant B's members. The member read now carries a strict organization_id equality (`businessUnitMemberScope`): the column is injected and only the session write path fills it, so NULL means unknown tenancy, not global. Existing fixtures that pinned org-less membership rows on stamped or seeded units are re-anchored onto stamped rows; the org-less case is pinned on its own (B3) as the declared, loud cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…presence; re-anchor the system-context page; ledger the new pin file - the fake engine's `find` no longer reads `this` (the objectql-double-limit probe calls it unbound) and applies the caller's bound after the filter, by presence, instead of a default page of 1000 - content/docs/permissions/system-context.mdx: six approval-service.ts line anchors re-aimed by +57, the net insertion of the businessUnitMemberScope docblock above them (check-system-context-census --fix) - engine-double-contract ledger learns business-unit-member-org-screen.test.ts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 22c9366b70e43fb841ceee8b73b6d81c0af9c29b && git checkout 22c9366b70e43fb841ceee8b73b6d81c0af9c29b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin da1cffb755ab5c528badc6401eaaf602b5210d74 16dbdf55bc418b2a17bfe6266e290acc16d7a252 && git checkout -B drift-repro da1cffb755ab5c528badc6401eaaf602b5210d74 && git merge --no-ff 16dbdf55bc418b2a17bfe6266e290acc16d7a252
node scripts/docs-audit/affected-docs.mjs --json da1cffb755ab5c528badc6401eaaf602b5210d74
|
Closes #14946
What was wrong
ApprovalService.expandBusinessUnitUsers(packages/plugins/plugin-approvals/src/approval-service.ts) screens thesys_business_unitrows throughbusinessUnitOrgScope— null-inclusive since #3807, because a seeded unit carriesorganization_id = nullby construction and is admitted on purpose — and then readsys_business_unit_memberwith no organization predicate at all, underSYSTEM_CTX, which carries no tenant either. A seeded unit id exists identically in every tenant, so adepartment:UNIT_IDapprover on tenant A's request resolved the shared unit and then collected every tenant's membership rows hanging off it: approval authority over A's record, routed to B's users.Measured red before the fix, on this branch with
approval-service.tsbyte-identical toorigin/main(commit6fc3d1009):The fix
The member read now goes through a new private
businessUnitMemberScope, a strictorganization_idequality against the same directory organization the unit screen uses (ADR-0105 D9directoryOrg, at both call sites — the staticdepartmenttype andexpression/resolveAs: 'department'). One read for the whole subtree, as before.After the fix, same probes at
16dbdf55b:Why strict, and not a copy of the unit screen's
$ornull arm (the A4 reading)The PM's dispatch asked whether a seeded member row is the same case as a seeded unit row, and whether the object even carries the column. Measured on this tree:
sys_business_unit_memberdeclares noorganization_id(packages/platform-objects/src/identity/sys-business-unit-member.object.ts). The column is injected byapplySystemFields(packages/objectql/src/registry.ts;packages/spec/src/data/injected-system-columns.ts,TENANT_SCOPE_COLUMN = 'organization_id'), and the committed tenancy census lists the object with"reach": "in","tenantField": "organization_id"(scripts/platform-object-tenancy-census.json).SqlDriver.injectTenantOnInsert). Seed replay does not (packages/metadata-protocol/src/seed-loader.tswithholds itsfallbackOrgIdfrom everysys_/cloud_/ai_object). Elevated system-context writes do not (sys_business_unit_memberisunclassifiedinPLATFORM_OBJECT_TENANCY, tracked insys_business_unit_memberis unadjudicated inPLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570, open).So a NULL on a membership row means unknown tenancy, not "platform-global" — the opposite of what a NULL on a seeded unit means — and routing must fail closed on it. This is the same ruling
plugin-sharing'smemberScopeapplies to the same rows (landed under #14547 / #14949 and deliberately kept by the #15078 revert, which dropped only the unit half), and the same posture this file already takes forsys_team_member(#10547) andsys_user_position. The sibling's answer and the right answer here agree; nothing to report as a divergence.Declared cost (confirmed, as the card asked, not assumed)
On a deployment whose membership rows (not merely its units) were seeded or written under a system context, a
departmentapprover on a request that carries an organization now expands to nobody. That is loud, not silent: the graph-type fallback already warnsapprover 'department:UNIT_ID' expanded to nobody(#3807), the slot falls to the literal, andonEmptyApproversgoverns the request as for any unstaffed target (admin_rescueby default). Pinned as B3. The repair on such a deployment is to stamp the membership rows (#14570), never to widen this screen.Measured against the stock example:
examples/app-showcase/src/data/seed/index.tsseeds business units only — its own comment says memberships "stay runtime admin actions", i.e. the session write path, which stamps the column. The showcase'sby_departmentapprover is therefore unaffected. Thepackages/qa/dogfoodsuites that insert org-less membership rows exercise sharing, not department approvers (nodepartmentapprover inpackages/qa/dogfood/test).Fixture triage in the existing suite
Four
approval-service.test.tsfixtures carried org-less membership rows on org-stamped or seeded units under at1context; each was re-judged rather than batch-edited. All four represent session-written rows, so they now carryorganization_id: 't1'(and't2'for the other tenant's row in the subtree case) and their assertions are unchanged — including the #3807 pins, which stay pins on the unit screen. The org-less-membership case is pinned on its own (B3) as the declared cost.Tests and checks (exit codes captured before any pipe; verdict lines quoted from the gates)
At
16dbdf55bunless stated:pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/business-unit-member-org-screen.test.ts→Tests 6 passed (6),GREEN_EXIT=0(red at6fc3d1009:5 failed | 1 passed,EXIT=1, above)pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2→Test Files 38 passed (38),Tests 671 passed (671),SUITE_EXIT=0pnpm --filter @objectstack/plugin-approvals typecheck→check:test-typecheck: OK,TYPECHECK_EXIT=0;tsc --listFilesshows the new test file in thetsconfig.test.jsonprogram andapproval-service.tsin the main programpnpm --filter '@objectstack/plugin-approvals^...' build(dependency closure) andpnpm --filter @objectstack/plugin-approvals build→command-exit 0node scripts/pm/dispatch-gates.mjs(derived from the working tree, not a hand list): 93 families at16dbdf55b(the mechanicalcontent/docs/**andscripts/**touches grew the list from the 56 the source-only diff owed); 90 exit 0, 3 exit 3 (PREREQUISITE NOT MET, listed below). The first-pass reds atfe370ce48—check-system-context-census(line rot, repaired with--fix),check:engine-double-contract(ledger,--write),check:objectql-double-limit(the fake engine'sfindreadthisand paged by default) — were repaired in16dbdf55band are green in this run.check:skill-exampleswas re-run after building@objectstack/client-react's closure:257 prose examples type-check across 3 surface(s).--no-inline-config --format json) on the three touched.tsfiles → 3 files judged, 0 errors, 0 warnings, exit 0. Declared narrowing ofpnpm lint: the repo's singleeslint.config.mjsnever enables type-aware linting (its own comment at line 328: noparserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict.pnpm check:nul-bytes→OK (scanned 7665 text file(s) ... no raw ASCII control bytes); unpiped control-character scan of the touched files: no hits.Not measured locally, declared:
check:dual-build-cjs-loads,check:i18n,check:type-check-debteach exit 3 — PREREQUISITE NOT MET (they read the built output of the whole workspace closure, which this worktree does not have). CI builds that closure before them. None of the three reads anything this diff changes in kind (no new package, no label/i18n source, and the new test file typechecks under the package's own test program above).Clause-② re-declaration: no
Measured against the built
dist/: the only new declaration isprivate businessUnitMemberScope;insidedeclare class ApprovalService(dist/index.d.ts), which TypeScript emits for a private member and no consumer can call; the export list ofdist/index.d.tsis unchanged, and no accepted key or value changes. Noneeds:contract-reviewlabel hung.Residue not fixed here
sys_business_unit_memberis unadjudicated inPLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 (open):sys_business_unit_memberunadjudicated inPLATFORM_OBJECT_TENANCY, so system-context writes still land org-less rows — the population B3 drops. Runtime classification, not a published surface.businessUnitOrgScope) is the released 17.x behaviour and is governed by ADR-0131 (D8/D14, fixed structurally on the v18 line by C1); not a published API surface (dist/index.d.tsexports no such symbol; nocontent/docs/**page documents the predicate).departmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807expanded to nobodywarning names the approver and the request organization but not why the expansion was empty; it is the existing diagnostic and was left as is.Two files outside
plugin-approvalsmoved mechanically:content/docs/permissions/system-context.mdx(sixapproval-service.tsline anchors re-aimed by +57 — the net size of the docblock inserted above them — viacheck-system-context-census --fix) andscripts/engine-double-contract.pinned.json(the ledger learns the new pin file via--write).Draft on purpose: not flipped ready, no auto-merge armed.
🤖 Generated with Claude Code
Generated by Claude Code