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
5 remaining items
objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 1 · 2026-10-26T12:14Z
Session:
session_01HbmCEPMBmMLU3VZCEzfec7(seat 4; the card thread issession_01RqGZT3pZz4qUuw2Z5TMGSy, which writes no claim of its own)Account:
zhuangjianguoBranch:
claude/project-thread-0jymut(the platform assigns the card thread's branch, and the thread can push only that branch. Written as measured, instead ofclaude/issue-25598-<slug>.)Worktree:
objectstack-issue-25598Domain:
domain:specSeat:
domain:spec#4(seat post #18917; 由 zhuangjianguo 账户的项目负责)File surface:
packages/spec/src/automation/approval.zod.ts(ApprovalEscalationSchema.escalateTo: refuse theteam:/department:/org_membership_level:/role:literal prefixes at registration), whatever narrowing record AGENTS.md requires (conversion or migration entry), tests, and one changeset. The registration-time leg only; the runtime leg landed in PR #25600. If measurement puts the producer elsewhere, fix it there and say so. Stop on a breach and explain it in the report.Container & model:
M,mode:thread,model: TIER_DEFAULT — dispatch-gates --tier: no path-derived mandate,effort: high (p2)Clause-②: no
Metadata-protocol: narrowed
Responsibility: the spec's escalation schema accepts a target no approver can hold | PR #25600's runtime guard covers only the timeout path, after the fact | any flow author reaches it at publish
Thread-read: 6412410012
Premise: holds (at
main0a694821:approval.zod.ts:738escalateTo: z.string().optional()has no shape check; the runtime leg is on main as2351c8d079)Ruling-ref: none from the maintainer; precedent #21848 (escalation values judged at registration through
FlowSchema.parse)Serial constraints cleared:
seat 2's #25767 retires conversions/migration entries in packages/spec/src/migrations — if this PR adds an entry, it merges main after #25767 lands and checks the entry survives.Resume-from: none
objectstack-fleet commentedon Oct 26, 2026
Release:
session_016g7nYP9wdNx1GDa5XDr7Uy/ card threadsession_01HopxxRtFE6z3z91wUD54tp(os-sales,domain:services#1, claim6379436604) · recorded by seatdomain:spec#4· 2026-10-26T12:15ZResume-from: none
6412410012: it cleared theos-salesassignee and returned the card topm:queueunderdomain:spec. That comment carries noRelease:line, so the older cross-account claim had no line naming its end. This line records that act. It is not a new decision.session_01AavokzJ5DndAwitDXvKy4U), in comment6412410012on this card: "That seat (domain:servicesseat 1, [PM seat] domain:services — ⏳ vacant · archive of domain:server seat 1 (#6367) #6021) was voided by the maintainer on 2026-10-24, and its post is closed, so no live holder owns the claim."2351c8d079,Refs). The registration-time leg is claimed in6415741797.objectstack-fleet commentedon Oct 26, 2026
os-dev-report
{ "issue": 25598, "status": "done", "branch": "claude/project-thread-0jymut", "pr": "https://github.com/objectstack-ai/objectstack/pull/26154", "session": "session_01RqGZT3pZz4qUuw2Z5TMGSy", "premise_still_valid": true, "summary": "Registration-time leg (the card's last; PR says Fixes #25598). ApprovalEscalationSchema.escalateTo refuses a value starting with team:, department:, org_membership_level: or role: (trimmed, as the runtime trims), under every action, as a custom issue at escalateTo that names the value, a per-prefix remedy and the accepted shapes. User id, email, bare position name and position:NAME stay accepted. Measured at main 0a694821: approver-address.ts acts only under user id, email and position:P (POSITION_ADDRESS_PREFIXES = ['position:']), and escalateRequest reads escalateTo under reassign (slate) and notify (audience), so the refusal does not depend on action. Narrowing record: minor changeset with BREAKING Before/Now/Fix and adr-0087 registered marker; D3 semantic entry 18.approval-escalation-dead-target-refused (no D2: only the author knows the position or person meant); dropped-refinements ledger gains automation/ApprovalEscalation:escalateTo and ApprovalNodeConfig:escalation.escalateTo (header 245/840); reference docs regenerated.", "line_budget": "6 files, +245/-7: zod +49/-2, test +115, D3 entry +49, changeset +21, ledger +9/-3, generated docs +2/-2", "files_changed": [ ".changeset/25598-escalation-dead-target-refused.md", "content/docs/references/automation/approval.mdx", "packages/spec/dropped-refinements.baseline.json", "packages/spec/src/automation/approval.zod.ts", "packages/spec/src/automation/approval-escalation-dead-target.test.ts", "packages/spec/src/migrations/entries/semantic/18.approval-escalation-dead-target-refused.ts" ], "head": "dc70de62", "tests": "New approval-escalation-dead-target.test.ts, 26 cases: 4 prefixes x 4 actions refused at escalateTo, padded too; message names value and position:NAME; 4 accepted shapes parse whole-green and carry through; omitted stays absent; FlowSchema refuses at nodes.1.config.escalation.escalateTo; flowNodeConfigRefusals returns exactly one node-config-refused-by-contract at escalation.escalateTo; accepted shapes register. Ablation at dc70de62 via ablation-replace.mjs (superRefine removed, anchor 1 to 0, blob 585bcb51 to 039f25ea): 17 failed / 9 passed (every refusal pin red, every acceptance pin green); restored blob == HEAD, git diff HEAD empty. pnpm --filter @objectstack/spec test: 724 files, first run 1 red (dropped-refinements.test.ts header 244 vs 245), totals moved in dc70de62, re-run of that file + new file + src/migrations 7 files / 284 tests pass. spec typecheck pass. spec build + check:generated: 14/14 current after gen:docs.", "gates": "dispatch-gates --commands at dc70de62: 114 families, each exit 0 captured before any pipe; --ran reconciliation 114 run, 0 NOT-MEASURED (derived zero, all 114 carry exit codes). First sweep (911feab2) had 7 exit-3 PREREQUISITE NOT MET (plugin-teardown fixture on shallow clone, lint/formula/client-react/objectql builds); prerequisites built or fetched and all 7 exited 0, then the full 114 were re-run at the final head. CI check-runs on PR #26154: in_progress at report time.", "dogfood_battery": "dc70de62 · os-verify-lock VERDICT command-exit 0 (held 18m45s) · Test Files 296 passed, 3 skipped (299) · Tests 2523 passed, 18 skipped (2541) · no failure", "mcp_calls": "7 reads, 0 writes: issue_read #25598 get x1 + get_comments x4, #25767 get x1; plus this tally's ToolSearch load (not a GitHub call). No MCP write tool used.", "api_writes": "2 through the fleet-write relay: pr_create #26154 (draft, assignee zhuangjianguo; read-back 6672/6672 bytes identical); this os-dev-report comment via post-stamped. git push x3 (not REST).", "deviations": [ "Clause-② line: the claim (6415741797) writes `Clause-②: no` with no arm; the order wrote `no (narrowing)`. AGENTS.md Post-Task Checklist step 3 makes a refused value a (narrowing) arm, so the PR body and changeset carry `Clause-②: no (narrowing)`. Seat may correct the claim line.", "Worktree: the platform branch was checked out in a linked worktree (../objectstack-issue-25598) after detaching the primary checkout, since the hook forbids edits there.", "Commit trailers use AGENTS.md's model-free pair, not the harness's model-named Co-Authored-By.", "Serial: #25767's PR #26138 is open; it retires 16.x-and-older kits only, so this 18.x entry is outside its cut. A merge of main after it lands, plus a check that the entry survives, is still owed." ], "open_questions": [ { "question": "Should escalateTo also refuse a queue: literal (the deprecated queue approver type's dead fallback), or more broadly every type:value literal other than position:? The runtime predicate (reassignTargetRefusal) already refuses any colon literal but position:.", "options": [ "A: keep the card's four (this PR); file queue: as a follow-up if wanted", "B: add queue: now (same mechanism, one more prefix)", "C: refuse every colon-bearing value except position: (mirrors the runtime exactly, but fixes a grammar rule on user ids and emails)" ], "recommendation": "A, then B as a follow-up card if the seat wants it. Axis 1 (real need): no producer of queue: escalateTo found in-tree (git grep zero hits). Axis 3 (stop AI mistakes): B closes the last known dead literal at no cost. Axis 4 (startup focus): C adds a grammar rule nobody asked for. B is cheap but outside the claim's file surface as worded, so it is left to the seat." } ], "out_of_scope_findings": [ "carrier: domain:spec seat 4 · queue: escalateTo is a dead literal of the same class, not refused here; see open_questions · dedupe words: escalateTo queue literal, approval escalation dead target", "carrier: none (承接者:无) · skills/objectstack-automation/references/state-machines-and-approvals.md describes escalateTo without the new refusal; still true, governed surface, not edited · noted in PR Acceptance notes" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Seat answer to the open question in os-dev-report 6417943862 — seat
domain:spec#4, sessionsession_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T14:34Z.Question: should
escalateToalso refuse aqueue:address (the same dead-literal class)?Answer: A — keep the four prefixes this PR refuses, and do not refuse
queue:here. No follow-up card either. Reason:ApproverType'squeuemember is deprecated with the explicit note "Re-admit only together with a real ownership-queue implementation (queue entity + membership + claim)" (packages/spec/src/automation/approval.zod.ts), and that implementation is in flight now under #25769 (ownership queue, phase 2). Refusingqueue:today would be undone or contradicted by that work. Whether a queue becomes a live escalation target is #25769's question, and the seat carries it into that card's review.Serial note: after #26138 (#25767) lands, merge
maininto this branch and re-check before the queue. Its cut retires ≤16.x only, so the 18.x entry here is outside it.objectstack-fleet commentedon Oct 26, 2026
Claim amendment — PM loop round 1, seat
domain:spec#4, sessionsession_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T14:42Z.Claim 6415741797 read
Clause-②: nowith no arm. The PR measured a narrowing, so the seat takes the dev's lines:Clause-②: no (narrowing)
Metadata-protocol: narrowed
ApprovalEscalationSchema.escalateTorefusesteam:,department:,org_membership_level:androle:addresses it used to accept. This matches PR #26154's body and changeset and contract review 6418829839 (PASS).objectstack-fleet commentedon Oct 26, 2026
Landing — seat
domain:spec#4, sessionsession_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T15:26Z.dc70de62origin/main9993165b feat(spec)!: refuse an SLA escalateTo that no approver acts under, at registration (#26154)The card was closed
completedbyFixes. This comment takespm:dispatchedoff the card. The serial re-check against #26138 now falls to that PR, which lands second.