Repository navigation
fix(plugin-approvals)!: refuse a reassign target that resolves to no one - #25592
Conversation
Claude-Session: https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 494f94ff480919977e1fa82c964dfdf6661292a3 && git checkout 494f94ff480919977e1fa82c964dfdf6661292a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e1e9022e04aa37af44268ff72e9bca76d0e85769 00d5635ad9aa7d4e838b00632242a4c3312bfbd1 && git checkout -B drift-repro e1e9022e04aa37af44268ff72e9bca76d0e85769 && git merge --no-ff 00d5635ad9aa7d4e838b00632242a4c3312bfbd1
node scripts/docs-audit/affected-docs.mjs --json e1e9022e04aa37af44268ff72e9bca76d0e85769
|
Contract reviewServed-tier: Inputs read: card #25485 body and both comments (triage 6370266628, claim 6378753403), PR #25592 body and file list (8 files, +411/−1, head repo = base repo), the net diff ① Derived judgmentsAccept set of the public door
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT · 2026-10-24T03:35Z
Generated by Claude Code |
This reverts commit 74fa3cb. The 18.1.0 publish was refused at that version commit: its full battery is red (Dogfood Regression Gate, the showcase D3/D4 proof pinning the pre-#25549 refusal sentence), and the release gate reads only runs at or before the version commit, so no re-run on it can go green. Reverting restores the 265 changesets it consumed, including the five that landed after the Version Packages PR was generated (#25591, #25592, #25596 and two patches), so the regenerated Version Packages PR versions 18.1.0 again from a tree whose battery can be read green, with a changelog that names everything it ships. Nothing was published: npm still has 18.0.0 and no 18.1.0 tag exists. Claude-Session: https://claude.ai/code/session_014C7q5Hm34g4EPdirar9SzN Co-authored-by: Claude <noreply@anthropic.com>
Requested by Sales · project thread
Fixes #25485
Clause-②: no (narrowing)
Metadata-protocol: none
What changed
ApprovalService.reassignnow resolvestobefore it writes anything. A target that resolves to no one is refused withVALIDATION_FAILED, which the route already answers as400, and the message names the address. The slate, the approver index, the audit trail and the notifications are left as they were.tosys_userrow, member here or with no membership rowssys_userrow (usr_does_not_exist,nobody-at-all)400: names no user in this request's organization400, same words as a missing user400position:<p>with a holder in the request's organizationposition:<p>that no one holds here400: no one holds positionphereteam:/department:/org_membership_level:/ a storedrole:400: a slot literal no approver acts under403 FORBIDDENand no directory read is made for the target. A pin asserts that no read names the target. The ordering ablation (check moved above the authorization) turns that pin red.sys_member(managerIsProvablyOutsideOrg,dropMembersProvablyOutsideOrg). Membership rows that name only other organizations mean refuse. No membership rows, or an unreadablesys_member, means there is no tenancy fact, and the target stands. A request with no organization skips the screen.sys_userread propagates, so the caller is told and nothing moves.expandPositionUsers, the routing read a position approver opens on.approver-address.tsgainsaddressedPosition(address), the inverse ofpositionAddresses, so theposition:prefix stays in the one module that owns it. The module is not exported from the package entry.isOverridewith no slot held) is held to the same rule. The override admits the caller; it does not exempt the new slot.VALIDATION_FAILEDis already this door's refusal (an emptytoand an already-pendingtouse it).packages/restis untouched.The changeset (
patch,**BREAKING**banner,(narrowing)arm, ADR-0087not-required (no-migration-prescription)) gives the FROM → TO and what a caller does.Tests
approval-service.test.tsgainsreassign target resolution (#25485), 10 cases. Each refusal asserts the named message, the slate and the approver index unchanged, noreassignaction row, and noapproval.reassignednotice:nobody-at-all, platform admin →usr_does_not_exist;position:ghostslate onto an unknown id;type:valueliterals;position:finance, whose holder then decides the request with the default actor.packages/qa/dogfood/test/approval-reassign-target-resolves.dogfood.test.tsdrives the real REST door on a booted stack, reusing the approvals: an empty slate underonEmptyApprovers: 'admin_rescue'opens on the tenant's administrators as named approvers, not as a nameless rescue request (retires the #22725unstaffedarm) #22824 fixture's staffed object. The holder (unknown id, bare word,team:literal) and the platform admin each get400 VALIDATION_FAILEDnaming the address. The slate is unchanged and noreassignrow exists. Then a reassign to a member of the organization answers 200, and the member's "My Pending" lists the request.Fixtures that reassigned to an id with no account now give it one:
approval-service.test.ts: astaffDirectory(engine, …ids)helper, called in the 11 tests that hand a slot tou7,u_nextoru_holder.admin-exemption-retired.test.tsandapproval-override-audit.test.ts: theirbeforeEachseedssys_useru7.actor-lookups-hold-ids.integration.test.ts: registers a minimalsys_user_positionand givesposition:financea holder (u_boss), so the ruling's "email handed to a position" hand-off still runs.Verification
pnpm --filter @objectstack/plugin-approvals exec vitest run: 95 files, 1244 tests, all passed.pnpm --filter @objectstack/plugin-approvals typecheckpassed,check:test-typecheckincluded (the ledger held at 324).pnpm --filter @objectstack/dogfood typecheckpassed. eslint on the 7 changed source and test files: clean.approval-service.tsrestored fromorigin/main:expected 200 to be 400), after a rebuild of the plugin'sdist.git status --porcelainwas empty.dispatch-gates --commands, 76 families): 74 exited 0 on the first pass.check-plugin-teardown-shape --self-testandcheck:dual-build-cjs-loadsexited 3 on missing prerequisites (the shallow clone's pinned fixture commit, 7 dists). Both exited 0 once the fixture commit was fetched and the dists were built.--ranreconcile: 76 derived, 76 run, 0 NOT-MEASURED.mainmerged at00d5635ad9; nothing incoming touchesplugin-approvals,restor the dogfood package. The full dogfood battery (owed by the(narrowing)arm) runs on this head next. Its counts go in theos-dev-reporton plugin-approvals: reassign accepts atothat resolves to no principal and strands the request on an unresolvable slate #25485.Acceptance notes
claude/project-thread-h5z02d, notclaude/issue-25485-…. Claim 6378753403 on plugin-approvals: reassign accepts atothat resolves to no principal and strands the request on an unresolvable slate #25485 is the identity record.approval-service.test.tsinstead, beside the reassign tests it already holds, so no new engine double was needed. The new file is the dogfood pin.action: 'reassign'(escalateRequest) falls back to the literalescalateTowhen its position expansion is empty. A config naming a user id that does not exist, or an unstaffed position, still writes an unresolvable slate on timeout. That target is authored in flow metadata, not sent to a door, so the right fix is at authoring or publish time, not here. Handed to the lane seat to file or cite.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp