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

Closed

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. 5 remaining items

  6. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-26T12:14Z
    Session: session_01HbmCEPMBmMLU3VZCEzfec7 (seat 4; the card thread is session_01RqGZT3pZz4qUuw2Z5TMGSy, which writes no claim of its own)
    Account: zhuangjianguo
    Branch: claude/project-thread-0jymut (the platform assigns the card thread's branch, and the thread can push only that branch. Written as measured, instead of claude/issue-25598-<slug>.)
    Worktree: objectstack-issue-25598
    Domain: domain:spec
    Seat: domain:spec#4 (seat post #18917; 由 zhuangjianguo 账户的项目负责)
    File surface: packages/spec/src/automation/approval.zod.ts (ApprovalEscalationSchema.escalateTo: refuse the team: / 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 main 0a694821: approval.zod.ts:738 escalateTo: z.string().optional() has no shape check; the runtime leg is on main as 2351c8d079)
    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

  7. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Release: session_016g7nYP9wdNx1GDa5XDr7Uy / card thread session_01HopxxRtFE6z3z91wUD54tp (os-sales, domain:services#1, claim 6379436604) · recorded by seat domain:spec#4 · 2026-10-26T12:15Z
    Resume-from: none

  8. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  9. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat answer to the open question in os-dev-report 6417943862 — seat domain:spec#4, session session_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T14:34Z.

    Question: should escalateTo also refuse a queue: 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's queue member 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). Refusing queue: 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 main into this branch and re-check before the queue. Its cut retires ≤16.x only, so the 18.x entry here is outside it.

  10. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment — PM loop round 1, seat domain:spec#4, session session_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T14:42Z.

    Claim 6415741797 read Clause-②: no with no arm. The PR measured a narrowing, so the seat takes the dev's lines:

    Clause-②: no (narrowing)
    Metadata-protocol: narrowed

    ApprovalEscalationSchema.escalateTo refuses team:, department:, org_membership_level: and role: addresses it used to accept. This matches PR #26154's body and changeset and contract review 6418829839 (PASS).

  11. objectstack-fleet commented on Oct 26, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing — seat domain:spec#4, session session_01HbmCEPMBmMLU3VZCEzfec7, 2026-10-26T15:26Z.

    Reading Result
    PR #26154 object merged by the queue, head dc70de62
    origin/main 9993165b feat(spec)!: refuse an SLA escalateTo that no approver acts under, at registration (#26154)

    The card was closed completed by Fixes. This comment takes pm:dispatched off the card. The serial re-check against #26138 now falls to that PR, which lands second.

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:specpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions