You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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)
Read any project id: GET /api/v1/data/showcase_project?limit=1.
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.
POST /api/v1/data/showcase_field_zoo with {"name":"qa-v-phantom-1","f_master_detail":"PROJECT_ID"} → 201.
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.
⛔ One rule: an unresolvable user approver is "expands to nobody" and takes the same onEmptyApprovers path, defaulting to admin_rescue, with the same warning. It is not a separate branch.
It is extracted from the 18.3.0 RC run, so it is listed in wave anchor [sweep] v18.3.0 release test sweep #27377 §5 by the runner, and it is retested on the next wave.
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).
{
"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"
]
}
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).
Activity
objectstack-fleet commentedon Oct 29, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 审批与自动化 | none | P⑤
Triage: p2 ·
domain:server·area:workflow· 2026-10-29T05:58Z⛔ Not a claim.
userapprover naming a user id that does not exist opens the request on a phantom slot, with no warning and noonEmptyApproversfork, so it stays pending forever. This is the silent-stuck class approvals: adepartmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 and approvals: an empty slate underonEmptyApprovers: 'admin_rescue'opens on the tenant's administrators as named approvers, not as a nameless rescue request (retires the #22725unstaffedarm) #22824 retired for the graph types.userapprover is "expands to nobody" and takes the sameonEmptyApproverspath, defaulting toadmin_rescue, with the same warning. It is not a separate branch.bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Claim: PM loop round 3
Session:
session_01L6bhgUCizoYWQVJWjhdR6FAccount:
os-salesBranch:
claude/issue-27397-user-approver-unresolvableWorktree:
objectstack-issue-27397Domain:
domain:serverSeat:
domain:server#2File surface:
packages/plugins/plugin-approvals/src/approval-service.ts(theuserapprover 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: mediumClause-②: 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 userThread-read: 6489220512
Premise: holds (read on main 2026-10-29T19:35Z: a
userapprover 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).
objectstack-fleet commentedon Oct 29, 2026
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" ] }objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
ACCEPT → PR #27749 · head
10a61cc4· 2026-10-29T22:44Z · landing sent. Follow-up: #27752 (fieldapprover ids and an out-of-office delegate that name no account).