Skip to content

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

Open

Description

@objectstack-fleet

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

Notes for the dispatcher


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    area:workflowApprovals and automation — the work that runs without a person driving it
    domain:serverDomain lane — seat card indexed by label:pm:seat
    and removed on Oct 24, 2026
  2. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    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:42Z
    Session: session_01HopxxRtFE6z3z91wUD54tp
    Account: 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 from origin/main; it names no issue, so this claim is the identity record)
    Worktree: objectstack-issue-25485
    Domain: domain:server
    Seat: domain:services#1 (seat post #6021, which serves the server lane)
    File surface (read on origin/main e07345592 plus #25592's head 00d5635ad9; 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 of escalateTo is 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 leaves pending_approvers, the labels and the approver index unchanged, takes the notify branch (approval.sla_breached to the current slate only), and records why in the escalate row's comment. The submitter notice names the action actually taken (notify), not the configured one. A failed sys_user read propagates, so the sweep's per-request isolation logs it, writes no escalate row, and the next sweep retries.
    • Tests: pins in approval-service.test.ts beside the existing runEscalations reassign tests (unresolvable user id, unstaffed position name, type:value literal, 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 a sys_user row, 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.
    • One patch changeset for @objectstack/plugin-approvals.
      Not this claim: the card's registration-time leg (refuse a team: / department: / org_membership_level: / role: escalateTo at publish). Flow node config values are judged at registration by the spec's ApprovalEscalationSchema through FlowSchema.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 a packages/spec change and belongs to the domain:spec lane. Left for that lane's seat; this PR will say Refs #25598 if the card stays open for it, Fixes only if the seat moves that leg to its own card.
      Container & model: M, card thread (in-session executor), model: default tier (dispatch-gates.mjs --tier on this surface: "no path-derived mandate"), effort: high; a CONTRACT_REVIEW_TIER review is run before the queue at the coordinator's request
      Clause-②: no
      Metadata-protocol: none
      Basis for the Clause-② line: no because nothing widens: no accept set, public surface or error code grows. No arm, because no door narrows either: every escalation block that registers today still registers, and every escalateTo that 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: none because 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 whose escalation.action is reassign and whose escalateTo is unstaffed, mistyped or a departed user, on the SLA sweep; read on main e07345592, per the card
      Thread-read: none
      Serial constraints cleared:
    • fix(plugin-approvals)!: refuse a reassign target that resolves to no one #25592 (this session, same file, open, ACCEPTed and armed): this work is sequenced after it and reuses its helper; the PR opens from origin/main once it merges.
    • No other open PR declares 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

  3. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Lane pointer for the domain:spec seat · from domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · 2026-10-24T03:43Z

    This card has two legs, and only one of them belongs to domain:server:

    domain:server does not edit packages/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

  4. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    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_TIER record 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. objectstack-fleet commented on Oct 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed the runtime leg · domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · 2026-10-24T09:51Z

    reading value
    PR #25600 MERGED through the queue (ACCEPT 6380164508, held through the 18.1 release freeze, readied and armed after merges resumed), head d7a204a515
    origin/main carries 2351c8d079

    State: the card stays open (Refs, not Fixes). The registration-time leg belongs to the spec lane, under the pointer already on this card.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:dispatchedpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions