Repository navigation
fix(plugin-approvals): an SLA reassign to a target that resolves to no one keeps the slate - #25600
Conversation
…o one keeps the slate
An approval node's `escalation: { action: 'reassign', escalateTo }` falls
back to the literal `escalateTo` when its position expansion is empty. A
literal that resolved to no one (an unstaffed or mistyped position, a user
id with no account, a `team:` literal) was written onto the slate on
timeout, stranding the request the way the reassign door did before #25592.
The fallback is now judged by the door's own predicate, split out as
`reassignTargetRefusal` (the door's assert is a thin throw over it). A
target that does not resolve leaves the slate, labels and approver index
as they were, takes the `notify` branch to the current slate, and the
`escalate` audit row records why. The submitter notice names the action
actually taken. A failed `sys_user` read throws before the audit row, so
the next sweep retries.
Claude-Session: https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 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 df7d5c84c171734a3ae467cbaa84aa234a95984c && git checkout df7d5c84c171734a3ae467cbaa84aa234a95984c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a57643d3de7067cb7d292b4802773bdf938fd2cb d7a204a5150c743ee4dec70f80783384444dc3b7 && git checkout -B drift-repro a57643d3de7067cb7d292b4802773bdf938fd2cb && git merge --no-ff d7a204a5150c743ee4dec70f80783384444dc3b7
node scripts/docs-audit/affected-docs.mjs --json a57643d3de7067cb7d292b4802773bdf938fd2cb
|
Contract reviewServed-tier: Inputs: card #25598 body and both comments (claim 6379436604, spec-lane pointer 6379444004); PR #25600 body, file list and the net diff ① Derived judgmentsAccept set: unchanged — Reusing the door predicate for Submitter notice reporting the action TAKEN: right. The template is a statement of fact ("escalation action 'ACTION' was taken", four locales), and it was false whenever Failed Tests. The two edits to existing fixtures ( Two observations, neither blocking. (a) The spec's ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT · 2026-10-24T05:03Z
Generated by Claude Code |
Requested by Sales · project thread
Refs #25598 (the runtime leg; the registration-time leg is a
packages/specchange, left for thedomain:speclane — claim 6379436604, "Not this claim")Clause-②: no
Metadata-protocol: none
What changed
An approval node's
escalation: { action: 'reassign', escalateTo }expandsescalateToas a position. When nobody holds it, the sweep falls back to the literal so that a config naming a user id keeps working. Nothing judged that literal, so an unstaffed or mistyped position, a user id with no account, or ateam:literal moved the overdue request off its real approvers and onto a slate nobody could decide. That is the state #25592 closed for the reassign door.The literal is now judged by the door's own predicate:
assertReassignTargetResolves(from fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592) is split intoreassignTargetRefusal(to, org), which returns the refusal sentence orundefined. The door's assert is now a thin throw over it, and its text and behaviour are unchanged (the plugin-approvals: reassign accepts atothat resolves to no principal and strands the request on an unresolvable slate #25485 pins pass as before).escalateRequestasks the same function. There is no second predicate.escalateTowithaction: 'reassign'position:<p>that someone here holdsposition:<p>position:<p>nobody holds here, ateam:/department:/org_membership_level:/role:literalpending_approvers,pending_approver_labelsand the approver index are left as they were.notifybranch:approval.sla_breachedgoes to the current slate, and the unresolvable literal is not in the audience.approval.escalatednotice is sent.escalateaudit row readsreassign → <target> — not applied: <why> The pending approvers were kept and notified.The row is still the single-shot marker, so the escalation fires once.escalation action 'reassign' was taken) even when no reassign happened. Now it reportsnotifyin that case, and in the existing case where noescalateTowas set.sys_userread throws before the audit row. The sweep's per-request isolation logs it and leaves the request untouched and unmarked, and the next sweep retries it.action: 'notify'is unchanged, including its audience, which still lists a literalescalateTo. So is every other escalation action.packages/specorpackages/restchanges. Everyescalationblock that registers today still registers.Tests
approval-service.test.tsgainsSLA reassign target resolution (#25598), 6 cases.sla_breachedaudience (the current slate only), noapproval.escalated, the submitter notice saying'notify', and that a second sweep is a no-op. The cases are:team:literal and aposition:ghostnobody holds.sys_userread: nothing moves, noescalaterow, no notice, and the next sweep with the directory back escalates.position:finance. The slate becomes the target, the audit comment is unchanged, and the submitter notice says'reassign'. The position's holder then decides with the default actor.boss, an id with no account, now give it one (staffDirectory):runEscalations: reassign replaces the approver set with escalateToandreassign and SLA-reassign rewrite the index rows.packages/qa/dogfood/test/approval-sla-reassign-target-resolves.dogfood.test.tsruns on a booted stack with the REST door and the real ObjectQL. Two approval flows reassign after one hour, one to a position nobody holds and one to a member's email. Both requests are made overdue (created_atmoved back), and the sweep runs through the registeredapprovalsservice.escalaterow says why, and onlyapproval.sla_breachedis recorded for it.Verification
All on
4525d322, whose tree is identical to the pushed headd7a204a5(the amend reworded the commit message only). That commit is one commit onmaina57643d3, after #25592 merged.pnpm --filter @objectstack/plugin-approvals exec vitest run: 95 files, 1250 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 3 changed source and test files: clean.approval-service.tsrestored from fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592's head00d5635a:distwas rebuilt, the dogfood pin went red on its first assertion:expected 'sla_reassign_nobody' to be 'sla-reassign-approver@example.com'.git status --porcelainwas empty.dispatch-gates --commands, 76 families): all 76 exited 0.showcase-d3-d4-capabilities.dogfood.test.ts:135([#9237] the grant the rule reconciles…) expects/SHARING_NOT_ENABLED: 'showcase_project' is not under record-sharing enforcement/.mainanswers…has a public sharing model, so a share row on it is consulted only for an external member of the record's organization…, the text feat(sharing): a share or sharing rule on a public object reaches an external member of the record's organization #25591 introduced, and it reproduces with that file run alone.Acceptance notes
claude/project-thread-h5z02d, restarted frommainafter fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592 merged. Claim 6379436604 on plugin-approvals: an SLA escalation withaction: reassignwrites a literalescalateTothat resolves to no one onto the slate, stranding the request the way #25485's door did #25598 is the identity record.Refs, notFixes: the card's second leg is refusing ateam:/department:/org_membership_level:/role:escalateToat registration. Approval node config values are judged by the spec'sApprovalEscalationSchemathroughFlowSchema.parse, the route automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848 took, so that leg ispackages/specwork. The services seat left thedomain:specseat a pointer on the card.notifydropstype:valueaudiences, so a reassign to a heldposition:<p>literal announces itself to no one. This is pre-existing and pinned as it is today.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp