Uh oh!
There was an error while loading. Please reload this page.
fix(approvals): screen the manager approver to the request's organization (#10153) - #10334
Conversation
…n (no fix) Pins the CURRENT behaviour so the premise and the tiering question are reproducible: the manager branch resolves across the organization boundary, the sibling position expansion is screened (and is not reject-everything), team is not screened either, and a sole cross-org manager approver under onEmptyApprovers: 'fail' opens today while a screened type in the identical shape throws NO_APPROVERS. No fix is implemented. The card tripped its re-tiering wire (Clause-2) and is handed back for dispatch at the required tier. Part of #10153 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…te dispatch check:engine-double-contract flagged the harness double's delete()/update() as looser than the engine they stand in for. Route both through assertEngineDeleteDispatch / assertEngineUpdateDispatch and record the new pinned coverage in the retained ledger, as the gate's own remedy prescribes. Part of #10153 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…nager-approver-org-screen
…zation (#10153) `lookupManager` read `sys_user.manager_id` with no organization argument while every other graph-shaped approver expansion is handed the directory org. Since `sys_user` carries no `organization_id`, a `manager_id` crossing an organization boundary routed the approval to an out-of-tenant approver. The screen is a `sys_member` membership test, applied only when the fact is present and negative: a manager with membership rows, none in the request's organization, is dropped; absent membership rows, a failed read, or a request with no organization leave routing exactly as it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…er at the renamed pin file The #4918 ratchet grandfathers `approval-service.ts` for its EXISTING query-options erasures only, so the new `sys_member` read carries no `as any`. The engine-double ledger follows the harness file's rename — same two pinned doubles, no coverage lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…eck in the hidden layer `plugin-approvals` excludes `**/*.test.ts` from its tsconfig, so `pnpm --filter @objectstack/plugin-approvals typecheck` never read this file and reported exit 0 over it. `check:type-check-debt --re-measure` did: the pins billed TEST_DEBT 21 raw TS2339/TS18048, all from reading `pending_approvers` straight off `ApprovalRequestRow | ApprovalNodeAutoOutcome`. Narrowed through an `opened()` helper that REFUSES the auto-approval arm rather than casting past it — every probe asserts something about an opened request, so an auto-approval reaching one is a wrong answer that must say so. Re-measured: the package is back to its frozen 348, contributing 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 3e7864a0b534cd350141b445bab01c2df1eb5d45 && git checkout 3e7864a0b534cd350141b445bab01c2df1eb5d45
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 75e9301372987d99b458d168d88d926bf09b9003 5f538cdf2f5d0d50f4390e6c68c6719bcfc2d091 && git checkout -B drift-repro 75e9301372987d99b458d168d88d926bf09b9003 && git merge --no-ff 5f538cdf2f5d0d50f4390e6c68c6719bcfc2d091
node scripts/docs-audit/affected-docs.mjs --json 75e9301372987d99b458d168d88d926bf09b9003
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10153
Clause-②: yes, developed one tier below the gateThis card moves one input from accepted to refused, and it was developed at
claude-opus-5under the maintainer's 2026-08-20 authorization ("while fable is exhausted, Clause-② cards may be developed atclaude-opus-5and wait for triage review before landing"). The compensating control is that triage reviews before merge — do not flip this ready, do not arm auto-merge.The flip, measured on this branch, both directions on one tree in one run:
manager,onEmptyApprovers: 'fail'pending, approvers["u_mgr_b"]NO_APPROVERSadmin_rescue)["u_mgr_b"]["manager:owner_id"]position) in the identical shape,'fail'NO_APPROVERSNO_APPROVERS(unchanged)Which callers are affected: only flows authored with the non-default
onEmptyApprovers: 'fail'whose slate ends up holding no concrete person. The defaultadmin_rescuestill opens the request (decidable by a privileged admin, with the existing loud warning), andauto_approvestill auto-approves. Nothing new is thrown: a screened-out manager leaves only atype:valueliteral, the pre-existing empty-slate test inapproval-service.tsalready classifies that as empty, and'fail'already threw on empty. Every org-screened sibling has reached that same bucket since it was written — this change only routes one more input into it.Both limbs are pinned (
C-a,C-a2), so the flip is a fact in the suite rather than a claim in this description.The defect
expandApprovershands the directory organization to every graph-shaped approver expansion —department,position,org_membership_level. Themanagerbranch did not:lookupManagerreadsys_user.manager_idunder a system context and took no organization argument at all.sys_useris a global identity table with noorganization_id, so nothing else on that path supplied the tenancy fact either. Amanager_idcrossing an organization boundary routed the submission to an approver in another organization.What the screen is
lookupManager(userId, organizationId)resolves the manager and then asks whether he is provably outside the request's organization, via asys_membermembership test:manager:owner_id-style literal, exactly where an unstaffedpositionlandssys_memberread threw"Provably" is deliberate, not a weaker version of "must prove membership". The fail-open half is this file's already-ruled posture on addressing paths, stated twice before this PR:
filterApproversWhoCanReadrefuses to empty a live slate on an infrastructure hiccup, andexpandPositionUserscarries "a step routing to nobody is worse than one routing to a lapsed holder". It is also load-bearing in practice — a stack that stamps an organization on its requests but never materializessys_memberrows would otherwise lose every manager approver at once, a far bigger behaviour change than the hole being closed. Measured: this repo's owntype:managerout-of-office fixture inapproval-service.test.tsis such a stack (itsCTXcarriestenantId: 't1'and seeds nosys_member), andexamples/app-showcase'sby_managerdemo node would be too.A drop is logged with the manager's id, the organizations he does hold, and the request's, so the remedy ("repair the link" / "grant the membership" / "retarget the step") is legible without a debugger.
Why
managercan be screened alone even though spec puts it in one class withteamThis has to be argued rather than assumed, because #10153's stated warrant does not survive contact with the source — round 1 measured that and filed it as #10230 / #10231. "Every sibling approver expansion is org-scoped" is false:
teamis not, andpackages/spec/src/automation/approval.zod.tsdeclaresteam: falsenext tomanager: falseinAPPROVER_ORG_SCOPED. Somanagerandteamsit in one declared class today, and screeningmanageralone splits it.The split is defensible on a fact the two do not share:
sys_teamcarries anorganization_id. A team id therefore transitively names exactly one organization — an author who picks a team has already picked an org, and the id itself is the tenancy fact.sys_user.manager_idnames a row in a global identity table with no tenancy fact at all. There is nothing in the id, nothing on the row, and nothing on the path.That asymmetry is why
manageris the sharper hole and why it can be closed on its own.teamis #10230's card — it touches this same file and is serialized behind this one. TheWpin in the test file assertsteamis still unscreened, so this PR's non-treatment of it is recorded rather than left ambiguous.APPROVER_ORG_SCOPEDis untouched. It answers ADR-0105 D9 retargetability (may an author writeorganization:on this type?), not screening, andmanager: falseremains correct —approver-cross-org.integration.test.tsstill requires aorganizationdeclaration onmanagerto be refused, and it still is.This does NOT decide #7497
The screen reads
sys_member, which looks identical to the D2 read-visibility filter beside it (filterApproversWhoCanRead). The code says at length why it is the sibling treatment instead, because the next reader will otherwise conclude #7497 was settled here:expandMembershipTierUsersfilterssys_member.organization_idoutright, and it is also the second limb ofexpandPositionUsers.sys_member.organization_idis already this file's answer to "which organization is this person in", independently of what they may read.sys_useroffers no other tenancy fact, so there is no other read this screen could have been written with.No reads are granted and no read screen is applied to any type that lacked one. I did not hit the #7497 fork.
Verification
Ten pins in
manager-approver-org-screen.test.ts, which is round 1's measurement harness inverted exactly as its own header instructed (AandC-aflipped;B/B2/C-b/Wunchanged, which is what makes the inversion readable — one screened sibling next to the newly screened one, same tree, same run).A2is the second direction: without it a screen that rejected everything would passA.No ADR-0112 envelope is minted here. The
NO_APPROVERSthrow is pre-existing code and a bareError, so there is nocode/statusto assert, and inventing one would be a fiction.C-aasserts the message prefix.Ablation
Predicted signature, stated before running: neuter
managerIsProvablyOutsideOrgandA,C-a,C-a2must fail whileA2/A3/A4/B/B2/C-b/Wstay green — 3 failed / 7 passed. Observed exactly that:Rebuild statement, argued from the files: none was required, and that is provable rather than assumed. The subject is imported relatively (
import { ApprovalService } from './approval-service.js'), which vitest resolves to this package'ssrc/, not itsdist/.check-test-source-alias.mjs'sKNOWN_UNALIASED_TEST_IMPORTSlists only cross-package specifiers for@objectstack/plugin-approvals(@objectstack/objectqland friends — used here for the fake engine's dispatch assertions, which the ablation does not touch). The observation is self-validating in the safe direction: adist-resolved test would have stayed green through a guard-removing mutation — the classic false green — and this one went red without any build, which is the proof the source is what ran.Restore is byte-identical, proved both sides, and the restore leg was re-run, not assumed:
Suites and gates — all on the final commit
5f538cdf2, exit codes captured before any pipepnpm --filter '@objectstack/plugin-approvals^...' build— dependency closure built first (os-verify-lock: VERDICT command-exit 0).pnpm --filter @objectstack/plugin-approvals test—Test Files 25 passed (25) / Tests 507 passed (507)(VERDICT command-exit 0).pnpm --filter @objectstack/plugin-approvals typecheck—EXIT=0, script name echoed (tsc --noEmit), not a zero-match.node scripts/pm/dispatch-gates.mjs, no paths passed — it derived the 4 changed paths from the merge base itself. Every listed family plus the convention-triggered ones ran green, each quoted from the gate's own verdict line:slot-lookup"ratchet holds: 107 unswept site(s) in 25 file(s), none new" ·engine-double-contract"OK — 333 pinned, 133 in the DEBT ledger, 2 exempt" ·where-matcher"265 matcher(s) discovered, 265 answer the combinator battery correctly or refuse it loudly" ·query-options-erasure"ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new" ·test-source-alias"OK — 72 packages with tests scanned" ·type-source-resolution"OK — 76 packages with a tsconfig.json scanned" ·nul-bytes"OK (scanned 6091 text file(s) ... no raw ASCII control bytes)" ·i18n"OK (9 package(s) — all bundles in sync)" ·type-check-coverage"OK — 64/77 workspace packages type-checked" · pluschangeset-gate-self-tests,objectui-changeset,cross-package-test-inputs,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-affected-docs, allEXIT=0.pnpm check:type-check-debt --re-measure(whole workspace built first) —EXIT=0, "33 ledger entr(ies) re-measured in 308.2s, 1924 raw tsc error(s) total, none above its recorded number. surplus: none". Round 1 had to declare this one un-run; it is run here.Two gate findings were fixed in-branch rather than routed around, and both are worth a reviewer's eye:
query-options-erasurewent red — the newsys_memberread had picked up the file's habitualas any, and the file is grandfathered for its existing erasures only. The read now carries no assertion (ApprovalEngine.findaccepts it as written); the count is back to its baseline 67.type-check-debt --re-measurewent red at +21 —plugin-approvalsexcludes**/*.test.tsfrom itstsconfig.json, sopnpm --filter ... typecheckreported exit 0 over a pin file carrying 21 raw TS2339/TS18048, all from readingpending_approversstraight off theApprovalRequestRow/ApprovalNodeAutoOutcomeunion. Narrowed through anopened()helper that refuses the auto-approval arm rather than casting past it. Re-measured: the package is back to its frozen 348, contributing 0. ⛔ No ledger entry was raised.Out of scope, filed
ApprovalRequestRowdeclares noorganization_id, butopenNodeRequeststamps one on the row it writes and returns #10331 —ApprovalRequestRow(the published contract) declares noorganization_id, thoughopenNodeRequeststamps one on the row it writes and returns. Surfaced by the hidden-layer typecheck above; the pins read the stamp off the persisted row instead, which is a workaround.teamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 —teamis not org-screened either. Not addressed here; out of scope for this card.TeamGraphService.managerOfdiscards itsorganizationIdargument — the manager-chain screen cited as this repo's governed side is not in this repo #10231 —TeamGraphService.managerOfdiscards itsorganizationIdargument. Not addressed here.Generated by Claude Code