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
plugin-approvals' own expandPositionUsers claims parity with PositionGraphService but ignores the ADR-0091 D2 validity window — an EXPIRED position assignment still routes approvals #8838
Found while implementing #8710 (a deactivated sys_position confers no sharing-rule shares). Out of scope for that PR, whose file surface is packages/plugins/plugin-sharing/src/sharing-rule-service.ts + its tests, and which deliberately touches no plugin-approvals file. Filed unassigned. Not a claim.
Two implementations, one of them claiming to be the other
"Who holds position P" exists twice, as two separate implementations — not one shared helper, which is worth recording on its own because several notes about this family assume the latter:
packages/plugins/plugin-sharing/src/position-graph.ts — PositionGraphService.expandPositionUsers (not exported from the package index; the sharing rule evaluator is its only consumer);
packages/plugins/plugin-approvals/src/approval-service.ts — a privateexpandPositionUsers method on ApprovalService.
The approvals copy's doc block states the parity as a guarantee:
> the same semantics as PositionGraphService in plugin-sharing, so an approval routes to exactly the users the sharing engine would expand for the same position.
Measured divergence (by reading both, no probe built)
They differ on the assignment validity window, ADR-0091 D2:
plugin-sharing projects valid_from / valid_until and drops rows failing isGrantActive(row, now) — its comment calls this out: "expired holders stop receiving position-recipient shares at resolution time, fail-closed";
plugin-approvals reads fields: ['user_id'] only and applies no window filter at all. It does import isGrantActive and applies it to sys_approval_delegation — so the omission is per-read, not a missing dependency.
So a user whose sys_user_position row expired last month is expanded by approvals and not by sharing. The parity sentence is false today on that axis, and it is the kind of false statement that gets trusted: it is the reason a reader stops comparing the two.
Why this is not simply "add the filter"
The same asymmetry #8613 recorded and the 2026-08-15 ruling on #8710 reaffirmed applies here: filtering an approval routing read is fail-open (a step that routes to nobody), while filtering a sharing read is fail-closed. So the two defensible remedies point in opposite directions and the choice is a ruling, not a cleanup:
Honour the window in approvals too — an expired assignment stops routing. Consistent with the promise in the comment, and with how the platform treats an expired grant everywhere else; costs a projection and re-opens the fail-open question for steps whose only holder has expired.
Whichever wins, the current state — divergent behaviour under a comment asserting they agree — is the one option that keeps costing, because the next author to add a consumer will read the comment rather than both bodies.
⚠️In-flight neighbour:#8652 is being implemented in plugin-approvals (a read-only approval visibility tier). Anyone picking this up should serialise behind it.
Found while implementing #8710 (a deactivated
sys_positionconfers no sharing-rule shares). Out of scope for that PR, whose file surface ispackages/plugins/plugin-sharing/src/sharing-rule-service.ts+ its tests, and which deliberately touches noplugin-approvalsfile. Filed unassigned. Not a claim.Two implementations, one of them claiming to be the other
"Who holds position P" exists twice, as two separate implementations — not one shared helper, which is worth recording on its own because several notes about this family assume the latter:
packages/plugins/plugin-sharing/src/position-graph.ts—PositionGraphService.expandPositionUsers(not exported from the package index; the sharing rule evaluator is its only consumer);packages/plugins/plugin-approvals/src/approval-service.ts— a privateexpandPositionUsersmethod onApprovalService.The approvals copy's doc block states the parity as a guarantee:
> the same semantics as
PositionGraphServiceinplugin-sharing, so an approval routes to exactly the users the sharing engine would expand for the same position.Measured divergence (by reading both, no probe built)
They differ on the assignment validity window, ADR-0091 D2:
valid_from/valid_untiland drops rows failingisGrantActive(row, now)— its comment calls this out: "expired holders stop receiving position-recipient shares at resolution time, fail-closed";fields: ['user_id']only and applies no window filter at all. It does importisGrantActiveand applies it tosys_approval_delegation— so the omission is per-read, not a missing dependency.So a user whose
sys_user_positionrow expired last month is expanded by approvals and not by sharing. The parity sentence is false today on that axis, and it is the kind of false statement that gets trusted: it is the reason a reader stops comparing the two.Why this is not simply "add the filter"
The same asymmetry #8613 recorded and the 2026-08-15 ruling on #8710 reaffirmed applies here: filtering an approval routing read is fail-open (a step that routes to nobody), while filtering a sharing read is fail-closed. So the two defensible remedies point in opposite directions and the choice is a ruling, not a cleanup:
activeare deliberately not applied here, because an approval step that routes to nobody is worse than one that routes to a lapsed holder"), which is the shape Does a DEACTIVATEDsys_positionstill receive sharing-rule shares?expandPositionUsersnever reads the catalogue row, so today it does #8710's ruling gave theactiveaxis.Whichever wins, the current state — divergent behaviour under a comment asserting they agree — is the one option that keeps costing, because the next author to add a consumer will read the comment rather than both bodies.
plugin-approvals(a read-only approval visibility tier). Anyone picking this up should serialise behind it.Needs triage, not a rider.
Generated by Claude Code