You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
teams-bu-membership: sharing-rule revocation is lazy — a BU moved OUT of a shared subtree keeps read access until the shared record is next written #7729
⚠️Security-relevant. Business-unit subtree expansion for unit_and_subordinates sharing rules resolves correctly through three levels and is symmetric on placement (0→1 inside the shared subtree, 1→0 on re-parenting out) — so the tree is genuinely load-bearing. The defect is timing: the placement or re-parent alone does not move the read. The share only materializes (or de-materializes) when the shared record is next written.
The risky direction: after a business unit is moved out of a shared subtree, its members keep read access until the shared record is next written — a lazy, unbounded-in-time revocation.
Root cause (located)
The sharing-rule recompute (rebind) triggers are bound only to the rule's own object. bindRuleHooks in packages/plugins/plugin-sharing/src/rule-hooks.ts iterates the rules and registers its afterInsert / beforeUpdate / beforeDelete / afterUpdate / afterDelete recompute hooks scoped to each rule's object_name (e.g. showcase_project, showcase_inquiry, showcase_task — what the boot log shows). It registers no hook on sys_business_unit or sys_business_unit_member changes.
So a BU tree edit (re-parent) or a membership edit never enqueues a recompute for rules with unit_and_subordinates recipients — the materialized sys_record_share grants are only recomputed when the rule's own object row is written. The sys_business_unit_member hooks that do exist (packages/plugins/plugin-sharing/src/primary-bu-projection.ts) only maintain the primary-BU projection, not the rule recompute.
Related but distinct (not duplicates): #4433 (deactivating a rule never withdraws its grants) and #4779 (predicate/multi writes not recomputed) — both a different trigger axis from BU tree/membership edits, both closed.
Notes for the checklist
The item's knownGap can be retired: it anticipated no showcase geometry consuming BU membership, but share_new_inquiries_with_field_ops does, so the clause ran. (Tracked in the consolidated checklist-maintenance card for this run.)
Seed-data-thin trap: a fresh boot seeds the BU tree but zerosys_business_unit_member rows, so the rule materializes nothing until a tester places someone.
Reproduction
Author/enable a unit_and_subordinates sharing rule on a record object (e.g. share_new_inquiries_with_field_ops).
Place a user in a BU inside the shared subtree, confirm they can read the shared record (=1).
Re-parent that BU out of the shared subtree — do not touch the shared record.
The user still reads the record (reparentedOut(no touch)=1).
Write the shared record; only then does the read drop to 0.
Source
Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).
Scope: packages/plugins/plugin-sharing/src/rule-hooks.ts (bindRuleHooks registers recompute hooks only on each rule's own object_name) plus whatever seam is needed to recompute on sys_business_unit / sys_business_unit_member changes for rules with unit_and_subordinates recipients. The de-materialization direction (a BU moved OUT keeps read until the shared record is next written) is the one that must be pinned as a security regression guard.
Same-package coordination, different file: #7676 is in flight on sharing-rule-service.ts (org-null visibility filter). Not the same file, no shared function — but the dev is instructed to merge origin/main before the final push and re-run the plugin-sharing suite in case #7676 has landed by then.
One judgment call is delegated with a reporting duty: a BU re-parent can fan out a large recompute, so if the correct shape needs batching/async rather than a synchronous hook, the dev reports that trade-off rather than silently choosing a design that does not scale. #4433 and #4779 (closed, different trigger axes) are required reading before touching the recompute path.
{
"issue": 7729,
"status": "done",
"premise_still_valid": true,
"pr": "https://github.com/objectstack-ai/objectstack/pull/7806",
"branch": "claude/issue-7729-bu-tree-share-recompute",
"commits": [
"6c8c3ff09 fix(plugin-sharing): recompute BU-tree sharing-rule grants on business-unit graph writes",
"95d506f42 test(plugin-sharing): pin the #7729 BU-graph revocation matrix",
"5543ef769 test(plugin-sharing): pin the #7729 fake engine to ObjectQL's update dispatch",
"060617d12 test(plugin-sharing): conjoin the new fake's $or with its sibling field keys"
],
"files_changed": [
"packages/plugins/plugin-sharing/src/bu-tree-recompute.ts (new, 318)",
"packages/plugins/plugin-sharing/src/bu-tree-recompute.test.ts (new, 473)",
"packages/plugins/plugin-sharing/src/sharing-rule-service.ts (+80, 0 deletions)",
"packages/plugins/plugin-sharing/src/sharing-plugin.ts (+17)",
"packages/plugins/plugin-sharing/src/index.ts (+10)",
".changeset/bu-tree-share-recompute.md (new)"
],
"summary": "Premise re-verified on origin/main by symbol and it holds: bindRuleHooks scopes its recompute hooks to each rule's own object_name, and a repo-wide search finds no hook registered on sys_business_unit anywhere; the only sys_business_unit_member hooks are primary-bu-projection.ts, which maintains sys_user.primary_business_unit_id and never touches the rule recompute. New bu-tree-recompute.ts binds afterInsert/afterUpdate/afterDelete on both BU tables under its own package id, so unbindAllRuleHooks (called on every rule rebind) cannot tear it down; it holds no rule snapshot, reading listRules() live, so a runtime-authored rule needs no rebind wiring. A new SharingRuleService.revokeRuleGrantsForRetiredRecipients is the recipient-axis twin of the existing record-axis revokes. Both directions now move with no write to the shared record.",
"recompute_design": {
"shape": "Synchronous complete revoke + asynchronous coalesced re-grant — the #4779 ruling applied on the RECIPIENT axis instead of the record axis, reusing its seam (ruleRegrantQueue) rather than inventing a second one, as the card instructed.",
"why_it_scales": "The revoke is scoped by recipient, not by record, which is what makes it affordable synchronously: one sys_record_share query for the rule's granted recipients, one subtree walk, one member query, and a chunked set-based delete of the difference. There is NO record scan, so the cost does not grow with how many records the rule matches — the exact scan RULE_RECOMPUTE_ROW_CAP exists because a write path cannot afford. A `granted.size === 0` short-circuit makes boot-time BU seeding (thousands of member inserts against an empty grant table) nearly free.",
"revocation_window": "ZERO. The withdrawal is complete before the BU write returns — this is deliberately NOT deferred, because the deferred thing would be the security half. Pinned by a test that blocks the re-grant queue with a gate before re-parenting: the grant is already gone while the gate is shut, so it cannot have come from the async pass.",
"what_IS_deferred": "Only the grant direction. A unit moved INTO a shared subtree needs up to (matched records x new members) new grants, which is the fan-out that must not sit on a write path; it is queued as a full evaluateRule pass, at most one outstanding per rule (coalesced, released before the pass runs so a write landing mid-pass queues a fresh one). Under-granting for the length of that queue is the wobble the #4779 ruling already accepts, and kernel:bootstrapped's backfill repairs a re-grant lost to a crash.",
"bulk_write_cost_stated": "A predicate write over the BU tree dispatches these hooks once per matched row and the synchronous half runs on EVERY dispatch — deliberately not memoised per write. Memoising would judge the whole batch from the tree as it looked at the first row, re-opening the hole for rows 2..N. The repeats are wasted work, not wrong work, each bounded by the per-rule cost above. The async half IS coalesced, so a thousand-row import collapses to one re-grant pass per rule.",
"no_new_queue_built": "No queueing mechanism was invented — nothing to escalate on that axis. The in-process serialized ruleRegrantQueue from #4779 already existed and is reused as-is."
},
"recipient_kinds_audited": {
"method": "Read expandRecipient, then every resolver it delegates to, and recorded which tables each actually queries — not assumed from the kind's name.",
"reads_the_BU_graph": [
"unit_and_subordinates — BusinessUnitGraphService.expandUsers (descendants BFS + sys_business_unit_member)",
"business_unit — SAME expandUsers call, so it walks the subtree too and is equally exposed"
],
"does_not": [
"user — the literal id, no expansion",
"team — TeamGraphService: sys_team_member, sys_member, sys_user",
"position — PositionGraphService: sys_user_position, sys_member (flat since ADR-0090 D3)",
"queue — returns an empty list"
],
"note": "The card said to check rather than assume it is only unit_and_subordinates, and that check found a second kind AND a separate defect — see out_of_scope_findings."
},
"tests": {
"plugin_sharing": "pnpm --filter @objectstack/plugin-sharing test -> Test Files 17 passed (17), Tests 446 passed (446), on top of a merged origin/main. typecheck -> tsc --noEmit clean.",
"sys_record_share_materialization": "pnpm --filter @objectstack/plugin-security test -> Test Files 47 passed (47), Tests 973 passed (973). Covers controlled-by-parent-sharing, row-write-widener-composition and vama-write-path-convergence. NOTE: 6 files first reported as failing were collection errors from an unbuilt dist in this fresh worktree (AGENTS.md section 9), not assertion failures — 'Failed to resolve entry for package @objectstack/plugin-sharing' then '@objectstack/service-i18n'. Building the closures cleared all 6; no assertion ever failed.",
"end_to_end": "packages/qa/dogfood showcase-bu-hierarchy-sharing.dogfood.test.ts + sharing-rule-criteria-required.dogfood.test.ts -> 2 files, 9 tests passed. These boot the real showcase stack and insert BU + membership rows under a SYSTEM context, which is exactly where the new hooks fire, so they are the real integration check on the do-not-skip-system-writes decision.",
"new_guard": "18 cases reproducing the card's matrix over an in-memory engine that dispatches hooks on write: inSubtree=1; re-parent OUT revokes with a write log asserting ZERO writes to showcase_inquiry; re-parent back IN re-grants; BU deactivate/delete revoke; membership add/remove both directions; member moved within the subtree keeps access; business_unit recipients covered. The seed-data-thin trap is respected — the fixture places its member explicitly, three levels down.",
"gates": "check:engine-double-contract -> OK, 150 pinned. check:nul-bytes -> OK (7139 files). check:changeset-gate-self-tests -> OK. Self-scan for control bytes over every file touched -> clean.",
"ci": "in_progress at report time — reporting at draft-PR time per the standing contract; CI convergence and the ready-flip are the PM's."
},
"ablation": {
"predicted_first": "Neutering the hook registration should flip the new behaviour and binding cases red while every pre-existing rule-recompute pin stays green. Predicted 9 red; predicted green: inSubtree (materialised by the explicit boot-backfill analog, not a hook), 'member moved within the subtree keeps access', the pure non-regression assertions, and the pure-function cases.",
"measured": "10 failed / 426 passed. All 10 in the new file; all 16 pre-existing files fully green — the existing rule-recompute pins are untouched, as required. Reproduced identically on a second run after the fake-engine fixes.",
"prediction_delta": "One test more than predicted. 'recomputes exactly the BU-tree rules when both kinds are present' was mis-bucketed as a not-called assertion; it in fact asserts the BU rule IS recomputed (toEqual(['srule_bu'])), so it is correctly red under ablation. The semantics were predicted right; the bucketing of that one case was not.",
"ablation_found_a_real_flaw_in_my_own_test": "The FIRST ablation run returned 15 red, not 10, and all the extras were 10s/15s TIMEOUTS rather than assertion failures. Cause: the security pin installs a gate on the module-scoped ruleRegrantQueue, and a gate left shut by a FAILING assertion blocks the chain forever, so every later test dies in whenIdle(). One clear regression would have arrived as 15 timeouts. Fixed by releasing the gate from afterEach unconditionally; the second ablation then read cleanly."
},
"neighbor_7676": {
"landed": true,
"as": "PR #7760, merged to main as 8669e5dab before my final push; picked up by the pre-push `git fetch origin main && git merge origin/main`.",
"same_file": "Yes — it touched sharing-rule-service.ts, the file I was warned about. The merge was textually CLEAN (no conflict) and my change to that file is purely additive: +80 lines, 0 deletions, appended next to its sibling revokes and far from #7760's region (listRules / getRule / a new private adminOrgScope).",
"semantic_check_not_just_clean": "The overlap is real and was checked rather than assumed: my handler calls listRules(), which is exactly what #7760 changed. #7760's adminOrgScope only widens when the caller carries an org (`if (!orgId) return where`), and my handler reads under SYSTEM_CTX, which carries no organizationId/tenantId — so the predicate on my path is byte-for-byte unchanged. Post-merge suite re-run: 446 passed (up from 436, i.e. #7760's 10 new tests all green alongside mine).",
"bonus_interaction": "#7760 also removed a loose `$or` matcher from sharing-rule.test.ts's fake (it short-circuited and dropped sibling field keys). My new fake carried the identical looseness. Dormant here — my reads run under a system context so listRules composes no `$or` — but fixed the same way rather than shipped, since a double looser than its contract is how a green suite ships a broken filter.",
"collision": "None. I did not need to change #7760's code, only to add beside it."
},
"escalations": [],
"deviations": [
"TOUCHED sharing-rule-service.ts, which the dispatch asked me to stay out of and to report rather than collide. It was genuinely needed: the revoke must re-expand the rule's recipients, and expandRecipient is a private method on that class. The alternative was to re-implement the recipient switch inside the new module, which would create a second de-facto contract for recipient expansion — a divergence between the two would silently break revocation, which is exactly what PD #12 forbids. Kept strictly additive (+80/-0) and far from #7676's region; merged and re-verified as described above. Zero collision in the event.",
"Bound the hooks so they do NOT skip isSystem writes — the opposite of bindRuleHooks. Its skip concerns grant MATERIALISATION, which the boot backfill redoes; here the payload is REVOCATION, and the realistic production trigger for a re-parent is an HRIS/directory sync, i.e. a system write. Skipping those would leave the hole open on the very path most likely to open it. primary-bu-projection.ts reached the same conclusion for the same table.",
"Field-narrowed afterUpdate to the columns the expansion actually reads (parent_business_unit_id / active / organization_id on the BU; business_unit_id / user_id on the member row), so a rename or an is_primary flip does no work. Anything unreadable falls through to 'recompute' — 'we cannot tell' must never collapse into 'nothing changed'."
],
"changeset": ".changeset/bu-tree-share-recompute.md — patch bump on @objectstack/plugin-sharing, English, leading with the security consequence: read access granted through a business-unit sharing rule is now withdrawn immediately when the unit is moved out of the shared subtree, deactivated or deleted, or when the membership row is removed, instead of surviving until the shared record happens to be written next.",
"out_of_scope_findings": [
"filed as #7807: the `business_unit` recipient expands the whole SUBTREE while the spec declares it as exactly one unit (no subtree) — expandRecipient routes it through the same BusinessUnitGraphService.expandUsers call as unit_and_subordinates, so the two declared widths are enforced identically. A potential over-grant, and it makes the distinction the ADR-0105 lint red-line draws between the kinds unenforceable. Filed unassigned and unlabeled for PM triage (a concrete defect, not observation-class); deliberately NOT fixed here, and this PR's coverage of both kinds stays correct whichever way #7807 is decided."
]
}
Updated report — supersedes the previous marked comment. Only the doc addition the PM requested and the fields it touches changed; everything else is unchanged and repeated here so this comment stands alone.
{
"issue": 7729,
"status": "done",
"premise_still_valid": true,
"pr": "https://github.com/objectstack-ai/objectstack/pull/7806",
"branch": "claude/issue-7729-bu-tree-share-recompute",
"commits": [
"6c8c3ff09 fix(plugin-sharing): recompute BU-tree sharing-rule grants on business-unit graph writes",
"95d506f42 test(plugin-sharing): pin the #7729 BU-graph revocation matrix",
"5543ef769 test(plugin-sharing): pin the #7729 fake engine to ObjectQL's update dispatch",
"060617d12 test(plugin-sharing): conjoin the new fake's $or with its sibling field keys",
"8f5b14bd9 docs(permissions): add the business-unit write as the fourth withdrawal moment"
],
"files_changed": [
"packages/plugins/plugin-sharing/src/bu-tree-recompute.ts (new, 318)",
"packages/plugins/plugin-sharing/src/bu-tree-recompute.test.ts (new, 473)",
"packages/plugins/plugin-sharing/src/sharing-rule-service.ts (+80, 0 deletions)",
"packages/plugins/plugin-sharing/src/sharing-plugin.ts (+17)",
"packages/plugins/plugin-sharing/src/index.ts (+10)",
"content/docs/permissions/sharing-rules.mdx (+2, -1)",
".changeset/bu-tree-share-recompute.md (new)"
],
"summary": "Premise re-verified on origin/main by symbol and it holds: bindRuleHooks scopes its recompute hooks to each rule's own object_name, and a repo-wide search finds no hook registered on sys_business_unit anywhere; the only sys_business_unit_member hooks are primary-bu-projection.ts, which maintains sys_user.primary_business_unit_id and never touches the rule recompute. New bu-tree-recompute.ts binds afterInsert/afterUpdate/afterDelete on both BU tables under its own package id, so unbindAllRuleHooks (called on every rule rebind) cannot tear it down; it holds no rule snapshot, reading listRules() live, so a runtime-authored rule needs no rebind wiring. A new SharingRuleService.revokeRuleGrantsForRetiredRecipients is the recipient-axis twin of the existing record-axis revokes. Both directions now move with no write to the shared record. PM addendum folded in: the docs page's definitive three-moment withdrawal table now carries the fourth moment.",
"scope_statement_requested_by_pm": "The fourth withdrawal moment applies ONLY to the two recipient kinds whose expansion reads the BU graph — unit_and_subordinates and business_unit. A user / team / position / queue rule is not recomputed by a business-unit write at all, not merely more cheaply. Stated in three places so it cannot be inferred wider: the new docs table row ('and only those two'), a dedicated scope callout in the PR body, and an assertion in the guard (revokeRuleGrantsForRetiredRecipients and evaluateRule are both asserted never-called for the four non-BU kinds).",
"recompute_design": {
"shape": "Synchronous complete revoke + asynchronous coalesced re-grant — the #4779 ruling applied on the RECIPIENT axis instead of the record axis, reusing its seam (ruleRegrantQueue) rather than inventing a second one, as the card instructed.",
"why_it_scales": "The revoke is scoped by recipient, not by record, which is what makes it affordable synchronously: one sys_record_share query for the rule's granted recipients, one subtree walk, one member query, and a chunked set-based delete of the difference. There is NO record scan, so the cost does not grow with how many records the rule matches — the exact scan RULE_RECOMPUTE_ROW_CAP exists because a write path cannot afford. A `granted.size === 0` short-circuit makes boot-time BU seeding (thousands of member inserts against an empty grant table) nearly free.",
"revocation_window": "ZERO. The withdrawal is complete before the BU write returns — this is deliberately NOT deferred, because the deferred thing would be the security half. Pinned by a test that blocks the re-grant queue with a gate before re-parenting: the grant is already gone while the gate is shut, so it cannot have come from the async pass.",
"what_IS_deferred": "Only the grant direction. A unit moved INTO a shared subtree needs up to (matched records x new members) new grants, which is the fan-out that must not sit on a write path; it is queued as a full evaluateRule pass, at most one outstanding per rule (coalesced, released before the pass runs so a write landing mid-pass queues a fresh one). Under-granting for the length of that queue is the wobble the #4779 ruling already accepts, and kernel:bootstrapped's backfill repairs a re-grant lost to a crash.",
"bulk_write_cost_stated": "A predicate write over the BU tree dispatches these hooks once per matched row and the synchronous half runs on EVERY dispatch — deliberately not memoised per write. Memoising would judge the whole batch from the tree as it looked at the first row, re-opening the hole for rows 2..N. The repeats are wasted work, not wrong work, each bounded by the per-rule cost above. The async half IS coalesced, so a thousand-row import collapses to one re-grant pass per rule.",
"no_new_queue_built": "No queueing mechanism was invented — nothing to escalate on that axis. The in-process serialized ruleRegrantQueue from #4779 already existed and is reused as-is."
},
"recipient_kinds_audited": {
"method": "Read expandRecipient, then every resolver it delegates to, and recorded which tables each actually queries — not assumed from the kind's name.",
"reads_the_BU_graph": [
"unit_and_subordinates — BusinessUnitGraphService.expandUsers (descendants BFS + sys_business_unit_member)",
"business_unit — SAME expandUsers call, so it walks the subtree too and is equally exposed"
],
"does_not": [
"user — the literal id, no expansion",
"team — TeamGraphService: sys_team_member, sys_member, sys_user",
"position — PositionGraphService: sys_user_position, sys_member (flat since ADR-0090 D3)",
"queue — returns an empty list"
],
"note": "The card said to check rather than assume it is only unit_and_subordinates, and that check found a second kind AND a separate defect — see out_of_scope_findings."
},
"docs": {
"changed": "content/docs/permissions/sharing-rules.mdx — 'three' -> 'four', plus one table row for a sys_business_unit / sys_business_unit_member write. The row keeps the table's existing granularity, states the asymmetry (recipients the rule no longer reaches are revoked before the write returns; the grant direction is queued and coalesced per rule), and scopes itself to the two BU-reading recipient kinds. Nothing else in the page was touched and no other page was tidied.",
"check_1_recoverability_paragraph": "CHECKED, no rewording needed — and confirmed rather than assumed. Its subject is an over-granting RULE recovered by switching it off or deleting it, i.e. the rule-write axis (row 1 of the table), which this change does not touch; the sentence stays exactly true. Its 'not on the next time somebody happens to touch the record' phrasing is in fact the same house line the new row echoes, so the two now reinforce each other. This change makes BU-driven grants converge sooner without altering that claim's subject.",
"check_2_recipient_kind_coverage": "Stated in sharing-rules.mdx's recipient table (lines 144-145) and NOT changed, deliberately. It reads `unit_and_subordinates` = subtree, `business_unit` = 'exactly that business unit (no subtree)' — which is the DECLARED contract and matches both the spec enum and the ADR-0105 lint red-line, so the doc is not wrong. The RUNTIME is: it walks the subtree for both, which is #7807. Rewriting the doc to match the runtime would document an over-grant as intended behaviour and fossilise it, so the page stays and #7807 owns the resolution. No tension with the new row either: even a unit-only expansion still reads sys_business_unit for its own active flag and sys_business_unit_member for its members, so it needs the recompute under BOTH readings.",
"sharing_service_mdx": "Says nothing about recipient kinds or the BU graph — absent, not wrong, so left alone per the scope-discipline instruction.",
"releases_untouched": "content/docs/releases/ not touched. The changeset is this PR's only input to the release notes."
},
"tests": {
"plugin_sharing": "pnpm --filter @objectstack/plugin-sharing test -> Test Files 17 passed (17), Tests 446 passed (446), on top of a merged origin/main. typecheck -> tsc --noEmit clean. Re-run after the doc commit: still 446/446.",
"sys_record_share_materialization": "pnpm --filter @objectstack/plugin-security test -> Test Files 47 passed (47), Tests 973 passed (973). Covers controlled-by-parent-sharing, row-write-widener-composition and vama-write-path-convergence. NOTE: 6 files first reported as failing were collection errors from an unbuilt dist in this fresh worktree (AGENTS.md section 9), not assertion failures — 'Failed to resolve entry for package @objectstack/plugin-sharing' then '@objectstack/service-i18n'. Building the closures cleared all 6; no assertion ever failed.",
"end_to_end": "packages/qa/dogfood showcase-bu-hierarchy-sharing.dogfood.test.ts + sharing-rule-criteria-required.dogfood.test.ts -> 2 files, 9 tests passed. These boot the real showcase stack and insert BU + membership rows under a SYSTEM context, which is exactly where the new hooks fire, so they are the real integration check on the do-not-skip-system-writes decision.",
"new_guard": "18 cases reproducing the card's matrix over an in-memory engine that dispatches hooks on write: inSubtree=1; re-parent OUT revokes with a write log asserting ZERO writes to showcase_inquiry; re-parent back IN re-grants; BU deactivate/delete revoke; membership add/remove both directions; member moved within the subtree keeps access; business_unit recipients covered. The seed-data-thin trap is respected — the fixture places its member explicitly, three levels down.",
"gates": "check:engine-double-contract -> OK, 150 pinned. check:docs-audit-scope -> OK (179 hand-written docs in sync; release-owned pages confirmed review-only). check:nul-bytes -> OK (7139 files). check:changeset-gate-self-tests -> OK. Self-scan for control bytes over every file touched -> clean.",
"ci": "in_progress at report time — reporting at draft-PR time per the standing contract; CI convergence and the ready-flip are the PM's."
},
"ablation": {
"predicted_first": "Neutering the hook registration should flip the new behaviour and binding cases red while every pre-existing rule-recompute pin stays green. Predicted 9 red; predicted green: inSubtree (materialised by the explicit boot-backfill analog, not a hook), 'member moved within the subtree keeps access', the pure non-regression assertions, and the pure-function cases.",
"measured": "10 failed / 426 passed. All 10 in the new file; all 16 pre-existing files fully green — the existing rule-recompute pins are untouched, as required. Reproduced identically on a second run after the fake-engine fixes.",
"prediction_delta": "One test more than predicted. 'recomputes exactly the BU-tree rules when both kinds are present' was mis-bucketed as a not-called assertion; it in fact asserts the BU rule IS recomputed (toEqual(['srule_bu'])), so it is correctly red under ablation. The semantics were predicted right; the bucketing of that one case was not.",
"ablation_found_a_real_flaw_in_my_own_test": "The FIRST ablation run returned 15 red, not 10, and all the extras were 10s/15s TIMEOUTS rather than assertion failures. Cause: the security pin installs a gate on the module-scoped ruleRegrantQueue, and a gate left shut by a FAILING assertion blocks the chain forever, so every later test dies in whenIdle(). One clear regression would have arrived as 15 timeouts. Fixed by releasing the gate from afterEach unconditionally; the second ablation then read cleanly."
},
"neighbor_7676": {
"landed": true,
"as": "PR #7760, merged to main as 8669e5dab before my final push; picked up by the pre-push `git fetch origin main && git merge origin/main`. A second fetch+merge after the doc commit reported 'Already up to date'.",
"same_file": "Yes — it touched sharing-rule-service.ts, the file I was warned about. The merge was textually CLEAN (no conflict) and my change to that file is purely additive: +80 lines, 0 deletions, appended next to its sibling revokes and far from #7760's region (listRules / getRule / a new private adminOrgScope).",
"semantic_check_not_just_clean": "The overlap is real and was checked rather than assumed: my handler calls listRules(), which is exactly what #7760 changed. #7760's adminOrgScope only widens when the caller carries an org (`if (!orgId) return where`), and my handler reads under SYSTEM_CTX, which carries no organizationId/tenantId — so the predicate on my path is byte-for-byte unchanged. Post-merge suite re-run: 446 passed (up from 436, i.e. #7760's 10 new tests all green alongside mine).",
"bonus_interaction": "#7760 also removed a loose `$or` matcher from sharing-rule.test.ts's fake (it short-circuited and dropped sibling field keys). My new fake carried the identical looseness. Dormant here — my reads run under a system context so listRules composes no `$or` — but fixed the same way rather than shipped, since a double looser than its contract is how a green suite ships a broken filter.",
"collision": "None. I did not need to change #7760's code, only to add beside it."
},
"escalations": [],
"deviations": [
"TOUCHED sharing-rule-service.ts, which the dispatch asked me to stay out of and to report rather than collide. It was genuinely needed: the revoke must re-expand the rule's recipients, and expandRecipient is a private method on that class. The alternative was to re-implement the recipient switch inside the new module, which would create a second de-facto contract for recipient expansion — a divergence between the two would silently break revocation, which is exactly what PD #12 forbids. Kept strictly additive (+80/-0) and far from #7676's region; merged and re-verified as described above. Zero collision in the event.",
"Bound the hooks so they do NOT skip isSystem writes — the opposite of bindRuleHooks. Its skip concerns grant MATERIALISATION, which the boot backfill redoes; here the payload is REVOCATION, and the realistic production trigger for a re-parent is an HRIS/directory sync, i.e. a system write. Skipping those would leave the hole open on the very path most likely to open it. primary-bu-projection.ts reached the same conclusion for the same table.",
"Field-narrowed afterUpdate to the columns the expansion actually reads (parent_business_unit_id / active / organization_id on the BU; business_unit_id / user_id on the member row), so a rename or an is_primary flip does no work. Anything unreadable falls through to 'recompute' — 'we cannot tell' must never collapse into 'nothing changed'."
],
"changeset": ".changeset/bu-tree-share-recompute.md — patch bump on @objectstack/plugin-sharing, English, leading with the security consequence: read access granted through a business-unit sharing rule is now withdrawn immediately when the unit is moved out of the shared subtree, deactivated or deleted, or when the membership row is removed, instead of surviving until the shared record happens to be written next.",
"out_of_scope_findings": [
"filed as #7807: the `business_unit` recipient expands the whole SUBTREE while the spec declares it as exactly one unit (no subtree) — expandRecipient routes it through the same BusinessUnitGraphService.expandUsers call as unit_and_subordinates, so the two declared widths are enforced identically. A potential over-grant, and it makes the distinction the ADR-0105 lint red-line draws between the kinds unenforceable. Filed unassigned and unlabeled for PM triage (a concrete defect, not observation-class); deliberately NOT fixed here, and this PR's coverage of both kinds stays correct whichever way #7807 is decided. It is also why the docs recipient table was left as-is — see docs.check_2."
]
}
The revoke/re-grant split is the right shape and is argued from the right principle. Revocation is scoped by recipient, not by record — one grant query, one recipient expansion, one chunked set-delete, no record scan — which is exactly what makes it affordable synchronously, so the revocation window is zero rather than "until someone writes the shared record". The expensive direction (a unit moved into a subtree, up to records × members new grants) is the one deferred, on the existing ruleRegrantQueue rather than a second mechanism. That is #4779's ruling applied on a new axis, and the security half is the half that stayed synchronous.
Binding outside the rebind seam is a real design catch.unbindAllRuleHooks runs on every rebind; a recompute bound inside it would be silently torn down. Holding no rule snapshot and reading listRules() live also means a runtime-authored rule is covered with no wiring — fewer moving parts than the alternative.
The declared deviation is accepted. I told this dev to stay out of sharing-rule-service.ts and report if it needed to go there; it went, and gave the reason that justifies it: expandRecipient is private to that class, and duplicating the recipient switch would create a second de-facto contract whose divergence would break revocation silently. Strictly additive (+80/−0), far from #7760's region, zero collision in the event. Reporting-not-guessing is exactly what the instruction was for.
Two judgment calls I want on the record as correct:
Hooks deliberately do NOT skip isSystem writes, the opposite of bindRuleHooks. The reasoning distinguishes payloads rather than copying a neighbour: that skip concerns materialisation, which the boot backfill redoes; this payload is revocation, and the realistic production trigger for a re-parent is an HRIS/directory sync — a system write. Skipping it would have made the fix inert exactly where it matters most.
The bulk-write cost is stated rather than hidden, and the refusal to memoise per batch is right: memoising would judge every row from the tree as it looked at row 1, re-opening the hole for rows 2..N. Wasted work beats wrong work on this path.
Ablation found a flaw in the test file itself — a queue gate left shut by a failing assertion turned one clear regression into 15 timeouts, fixed by releasing in afterEach. That is the second time this shift a suite's own legibility under failure got fixed before it could mislead someone.
Docs: the definitive three-moment withdrawal table now has its fourth row, scoped explicitly to the two BU-reading recipient kinds. The dev also checked the two adjacent claims I asked about and left both alone with reasons — notably declining to "fix" the recipient table, because that table states the declared contract while #7807 says the runtime is wider; rewriting it would have documented an over-grant as intended behaviour. Correct call.
One limit I found that is not in the report, recorded rather than blocking: the grants query uses limit: 100000. A rule holding more grants than that has its stale set computed from a truncated read, so the recipients beyond the cap keep their grants until the next boot backfill or record write — i.e. that rule degrades to the pre-#7729 behaviour instead of failing loudly. Not a regression and not worth holding a security fix for, but it is a silent cap on a revocation path; whoever next touches this function should paginate or log when the cap is hit. Not filed as a card — recorded here and in the seat post so it is found from the code.
Suites green on merged main: plugin-sharing 446, plugin-security 973, the two BU/sharing dogfood suites 9; ablation 10 red / 426 green, reproduced twice, all reds in the new file; check:engine-double-contract, check:docs-audit-scope, check:nul-bytes, check:changeset-gate-self-tests OK. The six initially-red plugin-security files were the unbuilt-dist collection-error trap, correctly diagnosed rather than reported as failures — the fourth instance of that trap this shift.
⚠️Not arming auto-merge yet. The merge queue is currently poisoned by an unrelated main breakage (#7793 — sys_api_key gained update without bulk, so api-methods-batch-conformance fails every full-suite build). Three of this lane's PRs have already burned queue builds on it and I have disarmed them; #7806 will be flipped ready and armed once #7803 lands and the queue is green again.
Activity
os-help commentedon Aug 11, 2026
Claim: PM loop (
domain:identityseat, #6022)Session:
session_01BVc1ekPpi6yaWywAUhfzfdBranch:
claude/issue-7729-bu-tree-share-recomputeWorktree:
objectstack-issue-7729Mode & model: M/L card (security-relevant: lazy, unbounded-in-time revocation),
mode:subagent(sanctioned fallback),model: opus.Scope:
packages/plugins/plugin-sharing/src/rule-hooks.ts(bindRuleHooksregisters recompute hooks only on each rule's ownobject_name) plus whatever seam is needed to recompute onsys_business_unit/sys_business_unit_memberchanges for rules withunit_and_subordinatesrecipients. The de-materialization direction (a BU moved OUT keeps read until the shared record is next written) is the one that must be pinned as a security regression guard.Same-package coordination, different file: #7676 is in flight on
sharing-rule-service.ts(org-null visibility filter). Not the same file, no shared function — but the dev is instructed to mergeorigin/mainbefore the final push and re-run the plugin-sharing suite in case #7676 has landed by then.One judgment call is delegated with a reporting duty: a BU re-parent can fan out a large recompute, so if the correct shape needs batching/async rather than a synchronous hook, the dev reports that trade-off rather than silently choosing a design that does not scale. #4433 and #4779 (closed, different trigger axes) are required reading before touching the recompute path.
Generated by Claude Code
os-help commentedon Aug 11, 2026
{ "issue": 7729, "status": "done", "premise_still_valid": true, "pr": "https://github.com/objectstack-ai/objectstack/pull/7806", "branch": "claude/issue-7729-bu-tree-share-recompute", "commits": [ "6c8c3ff09 fix(plugin-sharing): recompute BU-tree sharing-rule grants on business-unit graph writes", "95d506f42 test(plugin-sharing): pin the #7729 BU-graph revocation matrix", "5543ef769 test(plugin-sharing): pin the #7729 fake engine to ObjectQL's update dispatch", "060617d12 test(plugin-sharing): conjoin the new fake's $or with its sibling field keys" ], "files_changed": [ "packages/plugins/plugin-sharing/src/bu-tree-recompute.ts (new, 318)", "packages/plugins/plugin-sharing/src/bu-tree-recompute.test.ts (new, 473)", "packages/plugins/plugin-sharing/src/sharing-rule-service.ts (+80, 0 deletions)", "packages/plugins/plugin-sharing/src/sharing-plugin.ts (+17)", "packages/plugins/plugin-sharing/src/index.ts (+10)", ".changeset/bu-tree-share-recompute.md (new)" ], "summary": "Premise re-verified on origin/main by symbol and it holds: bindRuleHooks scopes its recompute hooks to each rule's own object_name, and a repo-wide search finds no hook registered on sys_business_unit anywhere; the only sys_business_unit_member hooks are primary-bu-projection.ts, which maintains sys_user.primary_business_unit_id and never touches the rule recompute. New bu-tree-recompute.ts binds afterInsert/afterUpdate/afterDelete on both BU tables under its own package id, so unbindAllRuleHooks (called on every rule rebind) cannot tear it down; it holds no rule snapshot, reading listRules() live, so a runtime-authored rule needs no rebind wiring. A new SharingRuleService.revokeRuleGrantsForRetiredRecipients is the recipient-axis twin of the existing record-axis revokes. Both directions now move with no write to the shared record.", "recompute_design": { "shape": "Synchronous complete revoke + asynchronous coalesced re-grant — the #4779 ruling applied on the RECIPIENT axis instead of the record axis, reusing its seam (ruleRegrantQueue) rather than inventing a second one, as the card instructed.", "why_it_scales": "The revoke is scoped by recipient, not by record, which is what makes it affordable synchronously: one sys_record_share query for the rule's granted recipients, one subtree walk, one member query, and a chunked set-based delete of the difference. There is NO record scan, so the cost does not grow with how many records the rule matches — the exact scan RULE_RECOMPUTE_ROW_CAP exists because a write path cannot afford. A `granted.size === 0` short-circuit makes boot-time BU seeding (thousands of member inserts against an empty grant table) nearly free.", "revocation_window": "ZERO. The withdrawal is complete before the BU write returns — this is deliberately NOT deferred, because the deferred thing would be the security half. Pinned by a test that blocks the re-grant queue with a gate before re-parenting: the grant is already gone while the gate is shut, so it cannot have come from the async pass.", "what_IS_deferred": "Only the grant direction. A unit moved INTO a shared subtree needs up to (matched records x new members) new grants, which is the fan-out that must not sit on a write path; it is queued as a full evaluateRule pass, at most one outstanding per rule (coalesced, released before the pass runs so a write landing mid-pass queues a fresh one). Under-granting for the length of that queue is the wobble the #4779 ruling already accepts, and kernel:bootstrapped's backfill repairs a re-grant lost to a crash.", "bulk_write_cost_stated": "A predicate write over the BU tree dispatches these hooks once per matched row and the synchronous half runs on EVERY dispatch — deliberately not memoised per write. Memoising would judge the whole batch from the tree as it looked at the first row, re-opening the hole for rows 2..N. The repeats are wasted work, not wrong work, each bounded by the per-rule cost above. The async half IS coalesced, so a thousand-row import collapses to one re-grant pass per rule.", "no_new_queue_built": "No queueing mechanism was invented — nothing to escalate on that axis. The in-process serialized ruleRegrantQueue from #4779 already existed and is reused as-is." }, "recipient_kinds_audited": { "method": "Read expandRecipient, then every resolver it delegates to, and recorded which tables each actually queries — not assumed from the kind's name.", "reads_the_BU_graph": [ "unit_and_subordinates — BusinessUnitGraphService.expandUsers (descendants BFS + sys_business_unit_member)", "business_unit — SAME expandUsers call, so it walks the subtree too and is equally exposed" ], "does_not": [ "user — the literal id, no expansion", "team — TeamGraphService: sys_team_member, sys_member, sys_user", "position — PositionGraphService: sys_user_position, sys_member (flat since ADR-0090 D3)", "queue — returns an empty list" ], "note": "The card said to check rather than assume it is only unit_and_subordinates, and that check found a second kind AND a separate defect — see out_of_scope_findings." }, "tests": { "plugin_sharing": "pnpm --filter @objectstack/plugin-sharing test -> Test Files 17 passed (17), Tests 446 passed (446), on top of a merged origin/main. typecheck -> tsc --noEmit clean.", "sys_record_share_materialization": "pnpm --filter @objectstack/plugin-security test -> Test Files 47 passed (47), Tests 973 passed (973). Covers controlled-by-parent-sharing, row-write-widener-composition and vama-write-path-convergence. NOTE: 6 files first reported as failing were collection errors from an unbuilt dist in this fresh worktree (AGENTS.md section 9), not assertion failures — 'Failed to resolve entry for package @objectstack/plugin-sharing' then '@objectstack/service-i18n'. Building the closures cleared all 6; no assertion ever failed.", "end_to_end": "packages/qa/dogfood showcase-bu-hierarchy-sharing.dogfood.test.ts + sharing-rule-criteria-required.dogfood.test.ts -> 2 files, 9 tests passed. These boot the real showcase stack and insert BU + membership rows under a SYSTEM context, which is exactly where the new hooks fire, so they are the real integration check on the do-not-skip-system-writes decision.", "new_guard": "18 cases reproducing the card's matrix over an in-memory engine that dispatches hooks on write: inSubtree=1; re-parent OUT revokes with a write log asserting ZERO writes to showcase_inquiry; re-parent back IN re-grants; BU deactivate/delete revoke; membership add/remove both directions; member moved within the subtree keeps access; business_unit recipients covered. The seed-data-thin trap is respected — the fixture places its member explicitly, three levels down.", "gates": "check:engine-double-contract -> OK, 150 pinned. check:nul-bytes -> OK (7139 files). check:changeset-gate-self-tests -> OK. Self-scan for control bytes over every file touched -> clean.", "ci": "in_progress at report time — reporting at draft-PR time per the standing contract; CI convergence and the ready-flip are the PM's." }, "ablation": { "predicted_first": "Neutering the hook registration should flip the new behaviour and binding cases red while every pre-existing rule-recompute pin stays green. Predicted 9 red; predicted green: inSubtree (materialised by the explicit boot-backfill analog, not a hook), 'member moved within the subtree keeps access', the pure non-regression assertions, and the pure-function cases.", "measured": "10 failed / 426 passed. All 10 in the new file; all 16 pre-existing files fully green — the existing rule-recompute pins are untouched, as required. Reproduced identically on a second run after the fake-engine fixes.", "prediction_delta": "One test more than predicted. 'recomputes exactly the BU-tree rules when both kinds are present' was mis-bucketed as a not-called assertion; it in fact asserts the BU rule IS recomputed (toEqual(['srule_bu'])), so it is correctly red under ablation. The semantics were predicted right; the bucketing of that one case was not.", "ablation_found_a_real_flaw_in_my_own_test": "The FIRST ablation run returned 15 red, not 10, and all the extras were 10s/15s TIMEOUTS rather than assertion failures. Cause: the security pin installs a gate on the module-scoped ruleRegrantQueue, and a gate left shut by a FAILING assertion blocks the chain forever, so every later test dies in whenIdle(). One clear regression would have arrived as 15 timeouts. Fixed by releasing the gate from afterEach unconditionally; the second ablation then read cleanly." }, "neighbor_7676": { "landed": true, "as": "PR #7760, merged to main as 8669e5dab before my final push; picked up by the pre-push `git fetch origin main && git merge origin/main`.", "same_file": "Yes — it touched sharing-rule-service.ts, the file I was warned about. The merge was textually CLEAN (no conflict) and my change to that file is purely additive: +80 lines, 0 deletions, appended next to its sibling revokes and far from #7760's region (listRules / getRule / a new private adminOrgScope).", "semantic_check_not_just_clean": "The overlap is real and was checked rather than assumed: my handler calls listRules(), which is exactly what #7760 changed. #7760's adminOrgScope only widens when the caller carries an org (`if (!orgId) return where`), and my handler reads under SYSTEM_CTX, which carries no organizationId/tenantId — so the predicate on my path is byte-for-byte unchanged. Post-merge suite re-run: 446 passed (up from 436, i.e. #7760's 10 new tests all green alongside mine).", "bonus_interaction": "#7760 also removed a loose `$or` matcher from sharing-rule.test.ts's fake (it short-circuited and dropped sibling field keys). My new fake carried the identical looseness. Dormant here — my reads run under a system context so listRules composes no `$or` — but fixed the same way rather than shipped, since a double looser than its contract is how a green suite ships a broken filter.", "collision": "None. I did not need to change #7760's code, only to add beside it." }, "escalations": [], "deviations": [ "TOUCHED sharing-rule-service.ts, which the dispatch asked me to stay out of and to report rather than collide. It was genuinely needed: the revoke must re-expand the rule's recipients, and expandRecipient is a private method on that class. The alternative was to re-implement the recipient switch inside the new module, which would create a second de-facto contract for recipient expansion — a divergence between the two would silently break revocation, which is exactly what PD #12 forbids. Kept strictly additive (+80/-0) and far from #7676's region; merged and re-verified as described above. Zero collision in the event.", "Bound the hooks so they do NOT skip isSystem writes — the opposite of bindRuleHooks. Its skip concerns grant MATERIALISATION, which the boot backfill redoes; here the payload is REVOCATION, and the realistic production trigger for a re-parent is an HRIS/directory sync, i.e. a system write. Skipping those would leave the hole open on the very path most likely to open it. primary-bu-projection.ts reached the same conclusion for the same table.", "Field-narrowed afterUpdate to the columns the expansion actually reads (parent_business_unit_id / active / organization_id on the BU; business_unit_id / user_id on the member row), so a rename or an is_primary flip does no work. Anything unreadable falls through to 'recompute' — 'we cannot tell' must never collapse into 'nothing changed'." ], "changeset": ".changeset/bu-tree-share-recompute.md — patch bump on @objectstack/plugin-sharing, English, leading with the security consequence: read access granted through a business-unit sharing rule is now withdrawn immediately when the unit is moved out of the shared subtree, deactivated or deleted, or when the membership row is removed, instead of surviving until the shared record happens to be written next.", "out_of_scope_findings": [ "filed as #7807: the `business_unit` recipient expands the whole SUBTREE while the spec declares it as exactly one unit (no subtree) — expandRecipient routes it through the same BusinessUnitGraphService.expandUsers call as unit_and_subordinates, so the two declared widths are enforced identically. A potential over-grant, and it makes the distinction the ADR-0105 lint red-line draws between the kinds unenforceable. Filed unassigned and unlabeled for PM triage (a concrete defect, not observation-class); deliberately NOT fixed here, and this PR's coverage of both kinds stays correct whichever way #7807 is decided." ] }Generated by Claude Code
Generated by Claude Code
os-help commentedon Aug 11, 2026
Updated report — supersedes the previous marked comment. Only the doc addition the PM requested and the fields it touches changed; everything else is unchanged and repeated here so this comment stands alone.
{ "issue": 7729, "status": "done", "premise_still_valid": true, "pr": "https://github.com/objectstack-ai/objectstack/pull/7806", "branch": "claude/issue-7729-bu-tree-share-recompute", "commits": [ "6c8c3ff09 fix(plugin-sharing): recompute BU-tree sharing-rule grants on business-unit graph writes", "95d506f42 test(plugin-sharing): pin the #7729 BU-graph revocation matrix", "5543ef769 test(plugin-sharing): pin the #7729 fake engine to ObjectQL's update dispatch", "060617d12 test(plugin-sharing): conjoin the new fake's $or with its sibling field keys", "8f5b14bd9 docs(permissions): add the business-unit write as the fourth withdrawal moment" ], "files_changed": [ "packages/plugins/plugin-sharing/src/bu-tree-recompute.ts (new, 318)", "packages/plugins/plugin-sharing/src/bu-tree-recompute.test.ts (new, 473)", "packages/plugins/plugin-sharing/src/sharing-rule-service.ts (+80, 0 deletions)", "packages/plugins/plugin-sharing/src/sharing-plugin.ts (+17)", "packages/plugins/plugin-sharing/src/index.ts (+10)", "content/docs/permissions/sharing-rules.mdx (+2, -1)", ".changeset/bu-tree-share-recompute.md (new)" ], "summary": "Premise re-verified on origin/main by symbol and it holds: bindRuleHooks scopes its recompute hooks to each rule's own object_name, and a repo-wide search finds no hook registered on sys_business_unit anywhere; the only sys_business_unit_member hooks are primary-bu-projection.ts, which maintains sys_user.primary_business_unit_id and never touches the rule recompute. New bu-tree-recompute.ts binds afterInsert/afterUpdate/afterDelete on both BU tables under its own package id, so unbindAllRuleHooks (called on every rule rebind) cannot tear it down; it holds no rule snapshot, reading listRules() live, so a runtime-authored rule needs no rebind wiring. A new SharingRuleService.revokeRuleGrantsForRetiredRecipients is the recipient-axis twin of the existing record-axis revokes. Both directions now move with no write to the shared record. PM addendum folded in: the docs page's definitive three-moment withdrawal table now carries the fourth moment.", "scope_statement_requested_by_pm": "The fourth withdrawal moment applies ONLY to the two recipient kinds whose expansion reads the BU graph — unit_and_subordinates and business_unit. A user / team / position / queue rule is not recomputed by a business-unit write at all, not merely more cheaply. Stated in three places so it cannot be inferred wider: the new docs table row ('and only those two'), a dedicated scope callout in the PR body, and an assertion in the guard (revokeRuleGrantsForRetiredRecipients and evaluateRule are both asserted never-called for the four non-BU kinds).", "recompute_design": { "shape": "Synchronous complete revoke + asynchronous coalesced re-grant — the #4779 ruling applied on the RECIPIENT axis instead of the record axis, reusing its seam (ruleRegrantQueue) rather than inventing a second one, as the card instructed.", "why_it_scales": "The revoke is scoped by recipient, not by record, which is what makes it affordable synchronously: one sys_record_share query for the rule's granted recipients, one subtree walk, one member query, and a chunked set-based delete of the difference. There is NO record scan, so the cost does not grow with how many records the rule matches — the exact scan RULE_RECOMPUTE_ROW_CAP exists because a write path cannot afford. A `granted.size === 0` short-circuit makes boot-time BU seeding (thousands of member inserts against an empty grant table) nearly free.", "revocation_window": "ZERO. The withdrawal is complete before the BU write returns — this is deliberately NOT deferred, because the deferred thing would be the security half. Pinned by a test that blocks the re-grant queue with a gate before re-parenting: the grant is already gone while the gate is shut, so it cannot have come from the async pass.", "what_IS_deferred": "Only the grant direction. A unit moved INTO a shared subtree needs up to (matched records x new members) new grants, which is the fan-out that must not sit on a write path; it is queued as a full evaluateRule pass, at most one outstanding per rule (coalesced, released before the pass runs so a write landing mid-pass queues a fresh one). Under-granting for the length of that queue is the wobble the #4779 ruling already accepts, and kernel:bootstrapped's backfill repairs a re-grant lost to a crash.", "bulk_write_cost_stated": "A predicate write over the BU tree dispatches these hooks once per matched row and the synchronous half runs on EVERY dispatch — deliberately not memoised per write. Memoising would judge the whole batch from the tree as it looked at the first row, re-opening the hole for rows 2..N. The repeats are wasted work, not wrong work, each bounded by the per-rule cost above. The async half IS coalesced, so a thousand-row import collapses to one re-grant pass per rule.", "no_new_queue_built": "No queueing mechanism was invented — nothing to escalate on that axis. The in-process serialized ruleRegrantQueue from #4779 already existed and is reused as-is." }, "recipient_kinds_audited": { "method": "Read expandRecipient, then every resolver it delegates to, and recorded which tables each actually queries — not assumed from the kind's name.", "reads_the_BU_graph": [ "unit_and_subordinates — BusinessUnitGraphService.expandUsers (descendants BFS + sys_business_unit_member)", "business_unit — SAME expandUsers call, so it walks the subtree too and is equally exposed" ], "does_not": [ "user — the literal id, no expansion", "team — TeamGraphService: sys_team_member, sys_member, sys_user", "position — PositionGraphService: sys_user_position, sys_member (flat since ADR-0090 D3)", "queue — returns an empty list" ], "note": "The card said to check rather than assume it is only unit_and_subordinates, and that check found a second kind AND a separate defect — see out_of_scope_findings." }, "docs": { "changed": "content/docs/permissions/sharing-rules.mdx — 'three' -> 'four', plus one table row for a sys_business_unit / sys_business_unit_member write. The row keeps the table's existing granularity, states the asymmetry (recipients the rule no longer reaches are revoked before the write returns; the grant direction is queued and coalesced per rule), and scopes itself to the two BU-reading recipient kinds. Nothing else in the page was touched and no other page was tidied.", "check_1_recoverability_paragraph": "CHECKED, no rewording needed — and confirmed rather than assumed. Its subject is an over-granting RULE recovered by switching it off or deleting it, i.e. the rule-write axis (row 1 of the table), which this change does not touch; the sentence stays exactly true. Its 'not on the next time somebody happens to touch the record' phrasing is in fact the same house line the new row echoes, so the two now reinforce each other. This change makes BU-driven grants converge sooner without altering that claim's subject.", "check_2_recipient_kind_coverage": "Stated in sharing-rules.mdx's recipient table (lines 144-145) and NOT changed, deliberately. It reads `unit_and_subordinates` = subtree, `business_unit` = 'exactly that business unit (no subtree)' — which is the DECLARED contract and matches both the spec enum and the ADR-0105 lint red-line, so the doc is not wrong. The RUNTIME is: it walks the subtree for both, which is #7807. Rewriting the doc to match the runtime would document an over-grant as intended behaviour and fossilise it, so the page stays and #7807 owns the resolution. No tension with the new row either: even a unit-only expansion still reads sys_business_unit for its own active flag and sys_business_unit_member for its members, so it needs the recompute under BOTH readings.", "sharing_service_mdx": "Says nothing about recipient kinds or the BU graph — absent, not wrong, so left alone per the scope-discipline instruction.", "releases_untouched": "content/docs/releases/ not touched. The changeset is this PR's only input to the release notes." }, "tests": { "plugin_sharing": "pnpm --filter @objectstack/plugin-sharing test -> Test Files 17 passed (17), Tests 446 passed (446), on top of a merged origin/main. typecheck -> tsc --noEmit clean. Re-run after the doc commit: still 446/446.", "sys_record_share_materialization": "pnpm --filter @objectstack/plugin-security test -> Test Files 47 passed (47), Tests 973 passed (973). Covers controlled-by-parent-sharing, row-write-widener-composition and vama-write-path-convergence. NOTE: 6 files first reported as failing were collection errors from an unbuilt dist in this fresh worktree (AGENTS.md section 9), not assertion failures — 'Failed to resolve entry for package @objectstack/plugin-sharing' then '@objectstack/service-i18n'. Building the closures cleared all 6; no assertion ever failed.", "end_to_end": "packages/qa/dogfood showcase-bu-hierarchy-sharing.dogfood.test.ts + sharing-rule-criteria-required.dogfood.test.ts -> 2 files, 9 tests passed. These boot the real showcase stack and insert BU + membership rows under a SYSTEM context, which is exactly where the new hooks fire, so they are the real integration check on the do-not-skip-system-writes decision.", "new_guard": "18 cases reproducing the card's matrix over an in-memory engine that dispatches hooks on write: inSubtree=1; re-parent OUT revokes with a write log asserting ZERO writes to showcase_inquiry; re-parent back IN re-grants; BU deactivate/delete revoke; membership add/remove both directions; member moved within the subtree keeps access; business_unit recipients covered. The seed-data-thin trap is respected — the fixture places its member explicitly, three levels down.", "gates": "check:engine-double-contract -> OK, 150 pinned. check:docs-audit-scope -> OK (179 hand-written docs in sync; release-owned pages confirmed review-only). check:nul-bytes -> OK (7139 files). check:changeset-gate-self-tests -> OK. Self-scan for control bytes over every file touched -> clean.", "ci": "in_progress at report time — reporting at draft-PR time per the standing contract; CI convergence and the ready-flip are the PM's." }, "ablation": { "predicted_first": "Neutering the hook registration should flip the new behaviour and binding cases red while every pre-existing rule-recompute pin stays green. Predicted 9 red; predicted green: inSubtree (materialised by the explicit boot-backfill analog, not a hook), 'member moved within the subtree keeps access', the pure non-regression assertions, and the pure-function cases.", "measured": "10 failed / 426 passed. All 10 in the new file; all 16 pre-existing files fully green — the existing rule-recompute pins are untouched, as required. Reproduced identically on a second run after the fake-engine fixes.", "prediction_delta": "One test more than predicted. 'recomputes exactly the BU-tree rules when both kinds are present' was mis-bucketed as a not-called assertion; it in fact asserts the BU rule IS recomputed (toEqual(['srule_bu'])), so it is correctly red under ablation. The semantics were predicted right; the bucketing of that one case was not.", "ablation_found_a_real_flaw_in_my_own_test": "The FIRST ablation run returned 15 red, not 10, and all the extras were 10s/15s TIMEOUTS rather than assertion failures. Cause: the security pin installs a gate on the module-scoped ruleRegrantQueue, and a gate left shut by a FAILING assertion blocks the chain forever, so every later test dies in whenIdle(). One clear regression would have arrived as 15 timeouts. Fixed by releasing the gate from afterEach unconditionally; the second ablation then read cleanly." }, "neighbor_7676": { "landed": true, "as": "PR #7760, merged to main as 8669e5dab before my final push; picked up by the pre-push `git fetch origin main && git merge origin/main`. A second fetch+merge after the doc commit reported 'Already up to date'.", "same_file": "Yes — it touched sharing-rule-service.ts, the file I was warned about. The merge was textually CLEAN (no conflict) and my change to that file is purely additive: +80 lines, 0 deletions, appended next to its sibling revokes and far from #7760's region (listRules / getRule / a new private adminOrgScope).", "semantic_check_not_just_clean": "The overlap is real and was checked rather than assumed: my handler calls listRules(), which is exactly what #7760 changed. #7760's adminOrgScope only widens when the caller carries an org (`if (!orgId) return where`), and my handler reads under SYSTEM_CTX, which carries no organizationId/tenantId — so the predicate on my path is byte-for-byte unchanged. Post-merge suite re-run: 446 passed (up from 436, i.e. #7760's 10 new tests all green alongside mine).", "bonus_interaction": "#7760 also removed a loose `$or` matcher from sharing-rule.test.ts's fake (it short-circuited and dropped sibling field keys). My new fake carried the identical looseness. Dormant here — my reads run under a system context so listRules composes no `$or` — but fixed the same way rather than shipped, since a double looser than its contract is how a green suite ships a broken filter.", "collision": "None. I did not need to change #7760's code, only to add beside it." }, "escalations": [], "deviations": [ "TOUCHED sharing-rule-service.ts, which the dispatch asked me to stay out of and to report rather than collide. It was genuinely needed: the revoke must re-expand the rule's recipients, and expandRecipient is a private method on that class. The alternative was to re-implement the recipient switch inside the new module, which would create a second de-facto contract for recipient expansion — a divergence between the two would silently break revocation, which is exactly what PD #12 forbids. Kept strictly additive (+80/-0) and far from #7676's region; merged and re-verified as described above. Zero collision in the event.", "Bound the hooks so they do NOT skip isSystem writes — the opposite of bindRuleHooks. Its skip concerns grant MATERIALISATION, which the boot backfill redoes; here the payload is REVOCATION, and the realistic production trigger for a re-parent is an HRIS/directory sync, i.e. a system write. Skipping those would leave the hole open on the very path most likely to open it. primary-bu-projection.ts reached the same conclusion for the same table.", "Field-narrowed afterUpdate to the columns the expansion actually reads (parent_business_unit_id / active / organization_id on the BU; business_unit_id / user_id on the member row), so a rename or an is_primary flip does no work. Anything unreadable falls through to 'recompute' — 'we cannot tell' must never collapse into 'nothing changed'." ], "changeset": ".changeset/bu-tree-share-recompute.md — patch bump on @objectstack/plugin-sharing, English, leading with the security consequence: read access granted through a business-unit sharing rule is now withdrawn immediately when the unit is moved out of the shared subtree, deactivated or deleted, or when the membership row is removed, instead of surviving until the shared record happens to be written next.", "out_of_scope_findings": [ "filed as #7807: the `business_unit` recipient expands the whole SUBTREE while the spec declares it as exactly one unit (no subtree) — expandRecipient routes it through the same BusinessUnitGraphService.expandUsers call as unit_and_subordinates, so the two declared widths are enforced identically. A potential over-grant, and it makes the distinction the ADR-0105 lint red-line draws between the kinds unenforceable. Filed unassigned and unlabeled for PM triage (a concrete defect, not observation-class); deliberately NOT fixed here, and this PR's coverage of both kinds stays correct whichever way #7807 is decided. It is also why the docs recipient table was left as-is — see docs.check_2." ] }Generated by Claude Code
Generated by Claude Code
os-help commentedon Aug 11, 2026
Review: ACCEPT — PR #7806 (PM session
session_01BVc1ekPpi6yaWywAUhfzfd; service/wiring/docs diff read line-by-line).The revoke/re-grant split is the right shape and is argued from the right principle. Revocation is scoped by recipient, not by record — one grant query, one recipient expansion, one chunked set-delete, no record scan — which is exactly what makes it affordable synchronously, so the revocation window is zero rather than "until someone writes the shared record". The expensive direction (a unit moved into a subtree, up to records × members new grants) is the one deferred, on the existing
ruleRegrantQueuerather than a second mechanism. That is #4779's ruling applied on a new axis, and the security half is the half that stayed synchronous.Binding outside the rebind seam is a real design catch.
unbindAllRuleHooksruns on every rebind; a recompute bound inside it would be silently torn down. Holding no rule snapshot and readinglistRules()live also means a runtime-authored rule is covered with no wiring — fewer moving parts than the alternative.The declared deviation is accepted. I told this dev to stay out of
sharing-rule-service.tsand report if it needed to go there; it went, and gave the reason that justifies it:expandRecipientis private to that class, and duplicating the recipient switch would create a second de-facto contract whose divergence would break revocation silently. Strictly additive (+80/−0), far from #7760's region, zero collision in the event. Reporting-not-guessing is exactly what the instruction was for.Two judgment calls I want on the record as correct:
isSystemwrites, the opposite ofbindRuleHooks. The reasoning distinguishes payloads rather than copying a neighbour: that skip concerns materialisation, which the boot backfill redoes; this payload is revocation, and the realistic production trigger for a re-parent is an HRIS/directory sync — a system write. Skipping it would have made the fix inert exactly where it matters most.Ablation found a flaw in the test file itself — a queue gate left shut by a failing assertion turned one clear regression into 15 timeouts, fixed by releasing in
afterEach. That is the second time this shift a suite's own legibility under failure got fixed before it could mislead someone.Docs: the definitive three-moment withdrawal table now has its fourth row, scoped explicitly to the two BU-reading recipient kinds. The dev also checked the two adjacent claims I asked about and left both alone with reasons — notably declining to "fix" the recipient table, because that table states the declared contract while #7807 says the runtime is wider; rewriting it would have documented an over-grant as intended behaviour. Correct call.
One limit I found that is not in the report, recorded rather than blocking: the grants query uses
limit: 100000. A rule holding more grants than that has its stale set computed from a truncated read, so the recipients beyond the cap keep their grants until the next boot backfill or record write — i.e. that rule degrades to the pre-#7729 behaviour instead of failing loudly. Not a regression and not worth holding a security fix for, but it is a silent cap on a revocation path; whoever next touches this function should paginate or log when the cap is hit. Not filed as a card — recorded here and in the seat post so it is found from the code.Suites green on merged main: plugin-sharing 446, plugin-security 973, the two BU/sharing dogfood suites 9; ablation 10 red / 426 green, reproduced twice, all reds in the new file;
check:engine-double-contract,check:docs-audit-scope,check:nul-bytes,check:changeset-gate-self-testsOK. The six initially-red plugin-security files were the unbuilt-distcollection-error trap, correctly diagnosed rather than reported as failures — the fourth instance of that trap this shift.mainbreakage (#7793 —sys_api_keygainedupdatewithoutbulk, soapi-methods-batch-conformancefails every full-suite build). Three of this lane's PRs have already burned queue builds on it and I have disarmed them; #7806 will be flipped ready and armed once #7803 lands and the queue is green again.Generated by Claude Code
test(plugin-sharing): type the #7729 spies from the service methods
fix(plugin-sharing): recompute unit_and_subordinates grants on busine…