Repository navigation
plugin-approvals: an SLA escalation with action: reassign writes a literal escalateTo that resolves to no one onto the slate, stranding the request the way #25485's door did #25598
Description
Filing gate: ① a product defect with a named location (finding class (a)). Filed by domain:services seat 1 (seat post #6021) · os-sales · session session_016g7nYP9wdNx1GDa5XDr7Uy. ⛔ Not a claim.
Reader: the domain:server lane, packages/plugins/plugin-approvals (ApprovalService.escalateRequest), on the next PR that touches approval escalation.
Dedupe (open AND closed): "approval escalation reassign escalateTo unresolvable position empty slate" and "SLA escalation escalateTo validation at publish". No hit. Nearest: #25485 / PR #25592, the reassign door, which is a different path; #21848, escalation values checked only at execution, closed, a precedent for registration-time checks.
reach: flow metadata escalation: { action: 'reassign', escalateTo: … } on an approval node, fired by the SLA sweep on timeout. Measured by reading main e073455929; not run live.
Source: card #25485's out-of-scope finding (os-dev-report 6379344074), confirmed in the cited code by contract review 6379337914 (③).
type: Bug
What is wrong
escalateRequest expands escalateTo as a position. When the expansion is empty (an unstaffed position, a mistyped name, a user id that does not exist), it falls back to the literal:
escalatees = await this.expandPositionUsers(escalateTo, raw.organization_id ?? null);
…
if (!escalatees.length) escalatees = [escalateTo];With action: 'reassign' it then writes pending_approvers: escalatees.join(','). The fallback exists on purpose, so that a config naming a specific user id keeps working. But nothing checks that the literal is a user who exists in the request's organization. On timeout, the request is moved off its real approvers onto a slate nobody can decide. Only an override actor can move it again. That is the state #25485 closed for the REST door.
Expected
- A reassign escalation whose target resolves to no one does not replace the slate. It applies the same predicate fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592 gives the door (
assertReassignTargetResolves: asys_userrow not provably in other organizations only, or aposition:with a holder in the request's organization). A target that does not resolve leaves the pending approvers in place, takes thenotifybranch (approval.sla_breachedto the current slate), and records why in theescalateaudit row's comment. - Where the target's shape can be judged without directory state (a
team:/department:/org_membership_level:/role:literal, which no approver acts under), refuse it at registration/publish, alongside 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's escalation checks (Prime Directive Add comprehensive test suite for Zod schema validation #12). Whether a position is staffed or a user exists is runtime state, so the runtime guard above is still owed.
Notes for the dispatcher
- This is a behaviour change on a timeout path. Read the
Clause-②arm against AGENTS.md at claim time. A changeset is owed. - Reuse fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592's helper rather than writing a second predicate. Sequence after fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592 merges.
- Pin both legs: an unresolvable target keeps the slate and notifies, and a resolvable user id or staffed position still reassigns.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 24, 2026
Claim: card thread ·
domain:server(services seat 1, seat post #6021, project run by the Sales account) · p2 · the runtime half (see "Not this claim") · 2026-10-24T03:42ZSession:
session_01HopxxRtFE6z3z91wUD54tpAccount:
os-sales(the seat's linked user; the card's assignee)Branch:
claude/project-thread-h5z02d(the harness-pinned branch for this session; it carries #25592 until that merges, then restarts fromorigin/main; it names no issue, so this claim is the identity record)Worktree:
objectstack-issue-25485Domain:
domain:serverSeat:
domain:services#1(seat post #6021, which serves the server lane)File surface (read on
origin/maine07345592plus #25592's head00d5635ad9; stop on a breach and explain it in the report):packages/plugins/plugin-approvals/src/approval-service.ts:escalateRequest,action: 'reassign'. When the position expansion ofescalateTois empty and the literal fallback would be written, the literal is judged by fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592's predicate (one helper: the door's assert becomes a thin throw over a reason-returning resolver, no second predicate). A target that resolves leaves today's behaviour as it is. One that does not leavespending_approvers, the labels and the approver index unchanged, takes thenotifybranch (approval.sla_breachedto the current slate only), and records why in theescalaterow's comment. The submitter notice names the action actually taken (notify), not the configured one. A failedsys_userread propagates, so the sweep's per-request isolation logs it, writes noescalaterow, and the next sweep retries.approval-service.test.tsbeside the existingrunEscalationsreassign tests (unresolvable user id, unstaffed position name,type:valueliteral, other-organization user: slate, index and labels unchanged, notice audience, audit comment, submitter notice action; preservation: existing user id, email, staffed position,position:<p>literal with a holder). Existing fixtures that escalate to an id with no account get asys_userrow, named in the PR. One dogfood pin on a booted stack: an overdue request whose escalation names no one keeps its approver and is notified; one naming a member moves to them.patchchangeset for@objectstack/plugin-approvals.Not this claim: the card's registration-time leg (refuse a
team:/department:/org_membership_level:/role:escalateToat publish). Flow node config values are judged at registration by the spec'sApprovalEscalationSchemathroughFlowSchema.parse(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's route), so that leg is apackages/specchange and belongs to thedomain:speclane. Left for that lane's seat; this PR will sayRefs #25598if the card stays open for it,Fixesonly if the seat moves that leg to its own card.Container & model:
M, card thread (in-session executor),model: default tier(dispatch-gates.mjs --tieron this surface: "no path-derived mandate"),effort: high; aCONTRACT_REVIEW_TIERreview is run before the queue at the coordinator's requestClause-②: no
Metadata-protocol: none
Basis for the Clause-② line:
nobecause nothing widens: no accept set, public surface or error code grows. No arm, because no door narrows either: everyescalationblock that registers today still registers, and everyescalateTothat resolves to a person still reassigns. What changes is the runtime outcome of a timeout whose target resolves to no one (it notifies instead of stranding the request).Metadata-protocol: nonebecause what an author can write, and what it means for a target that resolves, are unchanged (escalateTo's published text: "User id or position machine name to escalate to").Responsibility:
plugin-approvals(ApprovalService.escalateRequest) produces the risk | no platform path covers it: the only exit from the stranded slate is the privileged override | it reaches every request on a node whoseescalation.actionisreassignand whoseescalateTois unstaffed, mistyped or a departed user, on the SLA sweep; read onmaine07345592, per the cardThread-read: none
Serial constraints cleared:
origin/mainonce it merges.packages/plugins/plugin-approvals(open PR search at claim time: chore: version packages #24876 version packages, refactor(service-automation): move 42 engine bodies into five seam modules (#25222 slice 7) #25597 service-automation).Resume-from: none
Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Lane pointer for the
domain:specseat · fromdomain:servicesseat 1 (seat post #6021) ·os-sales·session_016g7nYP9wdNx1GDa5XDr7Uy· 2026-10-24T03:43ZThis card has two legs, and only one of them belongs to
domain:server:6379436604):ApprovalService.escalateRequestmust not write areassigntarget that resolves to no one. Thedomain:serverPR will sayRefs #25598, notFixes.escalation.escalateTois judged at registration byApprovalEscalationSchema(packages/spec/src/automation/approval.zod.ts, theescalateTofield) throughFlowSchema.parse. That is 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 for the other escalation values. The leg asks that ateam:/department:/org_membership_level:/role:literal be refused there, because no approver acts under those addresses (approver-address.ts). A user id, an email and aposition:stay accepted; whether they resolve is runtime state.domain:serverdoes not editpackages/spec. If the spec lane takes this leg, please claim it here, or split it into a spec card, and say which on this card. The card closes when both legs have landed.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
os-dev-report
{ "issue": 25598, "status": "done", "branch": "claude/project-thread-h5z02d", "pr": "https://github.com/objectstack-ai/objectstack/pull/25600", "session": "session_01HopxxRtFE6z3z91wUD54tp", "premise_still_valid": true, "summary": "Runtime leg only (PR says Refs #25598). escalateRequest's reassign judges the literal escalateTo fallback (empty position expansion) by the door's predicate: #25592's assertReassignTargetResolves is split into reassignTargetRefusal(to, org) returning the refusal sentence or undefined, and the door throws over it unchanged. An unresolvable target leaves pending_approvers, labels and the approver index as they were, takes the notify branch (approval.sla_breached to the current slate only, no approval.escalated), and the escalate audit row reads 'reassign → X — not applied: WHY The pending approvers were kept and notified.' The submitter notice names the action taken. A failed sys_user read throws before the audit row, so the next sweep retries. No spec, rest, export or error-code change; patch changeset, Clause-② no, Metadata-protocol none.", "tests": "pnpm --filter @objectstack/plugin-approvals exec vitest run: 95 files, 1250 passed. New block 'SLA reassign target resolution (#25598)' in approval-service.test.ts, 6 cases. Refusals: user id with no account; position name held only in another org; user whose only membership is another org; team: literal and position:ghost. Each asserts slate, labels and index unchanged, the audit comment verbatim, the sla_breached audience, no approval.escalated, the submitter notice saying 'notify', and single-shot. Failed sys_user read: nothing moves or is recorded, and the next sweep escalates. Preservation: an account's user id, its email, and a held position:finance whose holder then decides. Fixtures staffed: 'boss' in two existing escalation tests. New dogfood pin approval-sla-reassign-target-resolves.dogfood.test.ts (booted stack, two flows reassigning after 1h, made overdue via created_at, sweep through the approvals service): 1 passed. Ablation with approval-service.ts written from #25592's head 00d5635a: 5 refusal and failed-read pins red, the preservation pin green (expected); dogfood pin red after a dist rebuild (expected 'sla_reassign_nobody' to be 'sla-reassign-approver@example.com'); tree clean after restore. plugin-approvals typecheck incl. check:test-typecheck (ledger held at 324), dogfood typecheck, eslint on the 3 changed files: pass.", "gates": "dispatch-gates --commands (76 families) on 4525d322 (tree identical to the pushed head d7a204a5, whose amend reworded the message only): 76 exit 0. CI on d7a204a5: 42 check-runs, all gate-carrying runs success; dogfood shards, Verify CLI, Console Pin Gate, Temporal Conformance, Build Docs and two opt-in harnesses skipped on pull_request by design; 0 failed.", "dogfood_battery": "4525d322 · os-verify-lock · Test Files 1 failed, 292 passed, 3 skipped (296) · Tests 1 failed, 2508 passed, 18 skipped (2527) · the one failure is showcase-d3-d4-capabilities.dogfood.test.ts:135, which expects 'is not under record-sharing enforcement' where main now answers #25591's 'has a public sharing model…' text; it reproduces with that file alone, on main, and this diff touches no sharing code (the contract review notes it is filed as #25602) · the new pin passed", "mcp_calls": "reads only: issue_read (#25598 get + comments x2, #21848 get), search_pull_requests x1, search_issues x1, pull_request_read (#25592 get + get_status, #25600 get_check_runs); zero MCP write tools", "api_writes": "all through the fleet-write relay: label-write on #25598 (pm:dispatched, -pm:queue, assign os-sales); post-stamped claim 6379436604; pr_create #25600; pr_request_reviewers os-sales; label-write assign os-sales on #25600; contract-review record 6380146213 on #25600; this report", "open_questions": [], "out_of_scope_findings": [ "carrier: domain:spec seat (pointer 6379444004 on this card) · the card's registration-time leg: refuse a team:/department:/org_membership_level:/role: escalateTo at publish, via ApprovalEscalationSchema through FlowSchema.parse (#21848's route) · not done here, packages/spec is another lane", "carrier: whoever owns #25591 / #25602 · main's dogfood battery is red on showcase-d3-d4-capabilities.dogfood.test.ts:135 since #25591 changed the SHARING_NOT_ENABLED text · reported to the coordinator 04:2xZ" ] }Contract review:
CONTRACT_REVIEW_TIERrecord on head d7a204a, PR #25600 comment 6380146213, VERDICT PASS. It flags a landing-order constraint: the lane's main-red (#25602) should clear before this PR is armed, and the merge freeze for the release holds anyway.Generated by Claude Code
objectstack-fleet commentedon Oct 24, 2026
Landed the runtime leg ·
domain:servicesseat 1 (seat post #6021) ·os-sales·session_016g7nYP9wdNx1GDa5XDr7Uy· 2026-10-24T09:51Z6380164508, held through the 18.1 release freeze, readied and armed after merges resumed), headd7a204a515origin/main2351c8d079State: the card stays open (
Refs, notFixes). The registration-time leg belongs to the spec lane, under the pointer already on this card.Generated by Claude Code