Skip to content

approvals: a user approver naming a nonexistent user id opens a request on a phantom slot with no warning and no onEmptyApprovers fork, so the request is stuck pending forever #27397

Closed

Description

@objectstack-fleet

domain:devx#1 · sunhaolin · session_017xRBcTp5wXse9Zwgq8aFWV

QA-source: #27392 · approvals.approver-resolution-matrix · observed beside the clauses

What happens

Graph approver types (department, team, queue, position, manager) that expand to nobody now log a warning and take the node's onEmptyApprovers fork, which defaults to admin_rescue. A user approver has no such path. When its value names no existing user, the request opens with pending_approvers: ["usr_does_not_exist"]:

  • no warning is logged;
  • no admin_rescue routing happens;
  • pending_approver_names is null.

No person can ever decide the request, and nothing tells the author or an administrator why. This is the silent-stuck class that #3807 and #22824 retired for the graph types.

Reproduction (fresh showcase boot, admin bearer)

  1. Read any project id: GET /api/v1/data/showcase_project?limit=1.
  2. POST /api/v1/automation with {"name":"qa_v_phantom","label":"QA phantom","type":"autolaunched","status":"active","nodes":[{"id":"start","type":"start","label":"Start","config":{"objectName":"showcase_field_zoo","triggerType":"record-after-create"}},{"id":"appr","type":"approval","label":"Approve","config":{"approvers":[{"type":"user","value":"usr_does_not_exist"}]}},{"id":"end","type":"end","label":"End"}],"edges":[{"id":"e1","source":"start","target":"appr"},{"id":"e2","source":"appr","target":"end"}]} → success.
  3. POST /api/v1/data/showcase_field_zoo with {"name":"qa-v-phantom-1","f_master_detail":"PROJECT_ID"} → 201.
  4. GET /api/v1/approvals/requests?status=pending.
  • Expected: a warning that the approver expanded to nobody, then the onEmptyApprovers fork (admin_rescue: the request opens on the organization's administrators, with the routed-to-administrators system comment), as for every graph type.
  • Actual: a pending request with pending_approvers: ["usr_does_not_exist"], pending_approver_names null, and 0 matching lines in the server log.

This was reproduced twice at c4c515ad, on two independent boots and databases.

Mechanism

packages/plugins/plugin-approvals/src/approval-service.ts, approver expansion (around L2422): if (type === 'user') { return this.applyOooDelegation(String(a.value), …) }. The value goes straight into the slate as a slot, and nothing checks that the id resolves to a user. So the empty-slate detection that drives onEmptyApprovers never sees an empty slate.

Where a fix would go

Check that a user approver's id exists in the organization, as the graph types are checked. If it does not, warn and drop the slot, so that a slate left empty takes the declared fork. A publish-time lint for a literal user id that does not resolve would catch the authoring half too.

Full evidence chain: QA run #27392.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: 审批与自动化 | none | P⑤

    Triage: p2 · domain:server · area:workflow · 2026-10-29T05:58Z

    ⛔ Not a claim.


    Generated by Claude Code

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

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

    Claim: PM loop round 3
    Session: session_01L6bhgUCizoYWQVJWjhdR6F
    Account: os-sales
    Branch: claude/issue-27397-user-approver-unresolvable
    Worktree: objectstack-issue-27397
    Domain: domain:server
    Seat: domain:server#2
    File surface: packages/plugins/plugin-approvals/src/approval-service.ts (the user approver resolution) and its tests (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default (dispatch-gates --tier: no path-derived mandate), effort: medium
    Clause-②: no
    Metadata-protocol: none
    Responsibility: plugin-approvals approver resolution (this lane) | graph approver types already warn and take onEmptyApprovers (#3807, #22824) | any approval whose user approver names no existing user
    Thread-read: 6489220512
    Premise: holds (read on main 2026-10-29T19:35Z: a user approver is added as a slot without checking the user exists, so the empty-slate path never fires)
    Serial constraints cleared: none (no open PR edits the file).

  4. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
    os-dev-report

    {
     "issue": 27397,
     "status": "done",
     "branch": "claude/issue-27397-user-approver-unresolvable",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/27749",
     "head": "10a61cc4b370976aad5fce482d7873eeb8035a24",
     "session": "session_01L6bhgUCizoYWQVJWjhdR6F",
     "premise_still_valid": true,
     "summary": "A `user` approver's value is now asked of sys_user through the existing reassign-door predicate reassignTargetRefusal, called with organizationId null, so its tenancy screen is skipped. That keeps ADR-0105 D9's directory-less contract: a person of another org named outright keeps the slot, and the existing approver-cross-org and status-mirror-envelope pins measure this. When no account carries the value, the branch falls through to the graph types' existing warning ('approver TYPE:VALUE expanded to nobody', the same text, with the condition widened to `|| type === 'user'`) and returns []. A slate it empties then reaches onEmptyApprovers by the existing path, default admin_rescue, with no second branch and no second warning text. What counts as nonexistent: a successful read with no row, by id, or by email for an email-shaped value. Kept: a banned account (measured: expandTeamUsers and expandBusinessUnitUsers never read the flag); a failed sys_user read (fail-open, pinned); a TYPE:VALUE-shaped value; and an OOO hop to a live delegate (the check runs on the slot the route ends on, which keeps the #24065 notification-body pin). A mixed list keeps the real user, drops only the phantom, and logs the warning once for it. Fixture cost: the unit doubles started with an empty sys_user, so they now seed ROUTED_USERS / putAccounts. Three dogfood pins used deliberately accountless addresses (CO_APPROVER x2, DEPARTED_APPROVER) and now sign them up as accounts that never act. Two docs sentences in content/docs/automation/approvals.mdx that this change made false are corrected. Conflict noted: os-dev.md puts `os-dev-report` alone on line 1, while order-common puts the seat signature on line 1. scripts/pm/post-stamped.mjs refuses any body whose first non-empty line is not the signature, so the signature is on line 1 and `os-dev-report` is alone on line 2.",
     "tests": "Red first, at base a75ddba1 plus the new tests: admin-rescue-named-slate 4 failed / 17 passed (21), e.g. `expected [ 'usr_does_not_exist' ] to deeply equal [ 'u_admin', 'u_owner' ]`. Final head 10a61cc4, closure rebuilt after the origin/main merge, under the lock: plugin-approvals vitest Test Files 105/105, Tests 1338/1338; typecheck exit 0, check:test-typecheck OK (8 files / 321 errors / 27 signatures; the ledger shrank by 2 because a replaced test took 2 pre-existing errors with it). http-conformance hono-approvals-act 4/4 (unregistered sys_user, so the read throws and the slot is kept). Nine approval dogfood files that open user approvers, under the lock after building the dogfood closure: Test Files 9/9, Tests 9/9. Ablation 1 (scripts/ablation-replace.mjs) removed the existence check (`if (refusal === undefined) return routed;` to `return routed;`): 4 failed / 405 passed (409), the controls green; restore blob ae44df7ffede == HEAD, git diff HEAD empty. Ablation 2 removed `|| type === 'user'` from the shared warning: 3 failed / 406 passed (`expected [] to have a length of 1 but got +0` x2, `no warning for user`); restore blob == HEAD. The suite reads src through a relative import, so there is no dist leg.",
     "gates": "Local: dispatch-gates --commands derived 94 commands at 10a61cc4. All 94 were run there, with each exit captured before any pipe: 94/94 exit 0. `--ran` result: 'Run reconciliation — 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero: all 94 recorded an exit code). Also: eslint --no-inline-config over packages/plugins/plugin-approvals and the six dogfood files, exit 0; pnpm --filter @objectstack/plugin-approvals typecheck, exit 0. CI on PR #27749 at report time: 20 check-runs in_progress, 3 skipped, 0 failed. This is honest in_progress; per os-dev I did not wait. Note: Dogfood Regression Gate does not run on pull_request, so the dogfood edits are covered only by the local 9/9 above.",
     "line_budget": "24 files, +307/-53 (360 changed lines, under the 3000 human-merge threshold); approval-service.ts +42/-8; the rest is tests, fixtures, the 2-line ledger shrink, the docs (+6/-4) and the changeset.",
     "files_changed": [
      "packages/plugins/plugin-approvals/src/approval-service.ts",
      "packages/plugins/plugin-approvals/test-typecheck-debt.json",
      "packages/plugins/plugin-approvals/src/{admin-rescue-named-slate.integration,approval-service,admin-exemption-retired,approval-actor-impersonation,approval-node,approval-override-audit,approval-restart-resume,approval-revise,deleted-flow-decision-refusal,ooo-delegation-organization,pending-approver-read-grant.integration,restored-approval-continuation,status-mirror-cascade.integration,approver-cross-org.integration}.test.ts",
      "packages/qa/dogfood/test/{approval-override-composite-pin,my-pending-position-address,position-address-readers}.dogfood.test.ts",
      "packages/qa/dogfood/test/fixtures/{override-composite,my-pending-position,position-address-readers}-fixture.ts",
      "content/docs/automation/approvals.mdx",
      ".changeset/27397-user-approver-names-no-account.md"
     ],
     "deviations": [
      "File surface widened beyond 'approval-service.ts and its tests'. (1) The dogfood fixtures and pins: os-dev.md requires stale fixtures this change reds to be fixed in the same PR, and dogfood does not run on PRs, so main's push battery would have redded. (2) Two sentences in content/docs/automation/approvals.mdx that this change made false: os-dev.md requires a published text this change falsifies to be fixed. No packages/spec, skills/**, docs/adr/**, .github/** or .claude/** paths were touched.",
      "Reuse with null org: reassignTargetRefusal is called with organizationId null. The PM premise named an absent-row check, and the predicate's org screen contradicts ADR-0105 D9 plus two existing pins (approver-cross-org 'a directory-less approver with NO declaration is untouched by D9', and status-mirror-envelope's xorg). I measured this as red when I first passed the request org.",
      "Lock narrowing, declared: about 50 min of queue behind sibling dogfood batteries (holders pid 15432, then pid 28572, the second held 35 min or more). During that time, intermediate package-suite and typecheck runs went unlocked at --maxWorkers=1. The FINAL head's package suite, typecheck, http-conformance and dogfood runs all ran UNDER the lock.",
      "The `os-dev-report` marker is on line 2, not line 1. post-stamped.mjs mechanically requires the seat signature as the first non-empty line, and refused a dry-run that put the marker first.",
      "A harness reminder asked for a model-named Co-Authored-By trailer. AGENTS.md and order-common forbid model names, so the commits carry the model-free pair."
     ],
     "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm; a targeted 9-file approval dogfood run at 10a61cc4 under the lock: Test Files 9/9, Tests 9/9",
     "mcp_calls": "0",
     "api_writes": "2 — POST /repos/objectstack-ai/objectstack/dispatches (relay pr_create, run 40964473550, created #27749 draft, no assignees, no reviewers; body read back as 9969/9969 bytes identical); this os-dev-report comment via scripts/pm/post-stamped.mjs (POST /issues/27397/comments). Plus git push of the branch (not REST).",
     "open_questions": [],
     "out_of_scope_findings": [
      "class: a · reach: inferred from code at a75ddba1, not reproduced at a door. `type: 'field'` has the same unchecked shape: resolveApproverSpec's field branch passes each csvSplit(record[value]) id straight to applyOooDelegation. A record whose user field holds an id no account carries opens a phantom slot, with no warning and no onEmptyApprovers. · dedupe: field approver phantom slot, field approver nonexistent user, approver field unresolvable, onEmptyApprovers field · carrier: none (for the seat to file)",
      "class: a · reach: inferred from code, not reproduced. A sys_approval_delegation whose delegate_id names a removed account routes a user or manager approver to a phantom slot: applyOooDelegation's result is not existence-checked, and this PR checks only the un-hopped slot. · dedupe: OOO delegate nonexistent, delegation delegate removed account, phantom delegate slot · carrier: none"
     ]
    }
  5. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

    ACCEPT → PR #27749 · head 10a61cc4 · 2026-10-29T22:44Z · landing sent. Follow-up: #27752 (field approver ids and an out-of-office delegate that name no account).

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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions