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
plugin-approvals: a manager approver whose owner has no manager_id lands as the literal manager:undefined in pending_approvers, where the documented contract says the request opens with an empty slate (17.7.0) #22558
Filing class: ① product defect — reach: public door (GET /api/v1/data/sys_approval_request, GET /api/v1/approvals/requests), measured on 17.7.0.
Reader: objectstack triage → the lane owning plugin-approvals.
Symptom
Measured in objectstack-ai/hotclm (report on objectstack-ai/hotclm#86). An approval step with approver { type: 'manager' }, submitted for a record whose owner has no manager_id (no product surface in the app writes it; 0 of 7 users carry one): both doors answer pending_approvers: ["manager:undefined"].
Expected
content/docs/automation/approvals.mdx: "An entry that resolves to nobody is not an error: the request opens with an empty pending_approvers" — and the node's onEmptyApprovers policy (#17931) then decides what happens. A literal manager:undefined is neither: it reads as a real slot, matches nobody, and keeps onEmptyApprovers from firing.
Where (from the dev's source read, for triage to confirm)
ApprovalService.expandApprovers falls back to the literal type:value string when an approver does not expand; a manager approver carries no value, so the fallback prints undefined.
Dedupe
MCP search_issues on this repo: pending_approvers manager:undefined manager approver no manager_id expandApprovers literal fallback → 0 hits; control query approval manager approver unresolved empty pending approvers → 18 hits (instrument reaches the subject), none open and none on this: nearest #17931 (closed — onEmptyApprovers: 'fallback'), #16748 (closed — the lint rule that covers manager), #10153 (closed — manager org scoping).
Filed by the repo:hotclm PM seat from a measured dev finding.
In expandApprovers, an approver of a user-resolving type (manager and the others that resolve to users) that resolves to nobody adds no slot.
The literal type:value fallback stays only for types whose literal is the slot, if any exist. Name them in the PR.
Then an empty set triggers onEmptyApprovers, as content/docs/automation/approvals.mdx states.
Stored rows: measure whether pending requests already hold a …:undefined slot. The PR states their disposition: re-evaluated on the next transition, or listed for the operator. ⛔ No silent rewrite.
Pins:
a manager step for an owner without a manager_id opens with an empty pending_approvers, and onEmptyApprovers fires;
control: an owner with a manager gets that manager's slot.
Claim: PM loop round 1 · 2026-10-10T01:09Z
Session: session_013j5gkUCpqQiti4GgPqqmnt
Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-22558-empty-approver-no-slot
Worktree: objectstack-issue-22558
Domain: domain:services
Seat: domain:services#1 (seat post #6021)
File surface: packages/plugins/plugin-approvals/ only, read on origin/main6a3f82efa7:
approval-service.ts, the approver expansion region:expandApprovers (about :1747) and resolveApproverSpec (about :1816), where a spec that resolves to nobody falls back to the literal type:value string. An approver of a user-resolving type that resolves to nobody adds no slot, so an empty set reaches the node's onEmptyApprovers policy. The literal stays only for a type whose literal is a slot a holder acts under (approver-address.ts); the PR names each kept type and why.
Tests in plugin-approvals, covering triage's pins (6091880508).
.changeset/22558-empty-approver-no-slot.md:patch for @objectstack/plugin-approvals.
Stored rows: measured, not rewritten. The PR states their disposition (re-evaluated at the next transition, or listed for the operator). ⛔ No silent rewrite.
⛔ No packages/spec, no content/docs (the documented sentence is the contract this aligns to), no other package.
Stop on breach; explain in the report.
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; the default tier, for an approval-routing judgement)
Clause-②: no
The runtime is brought to the contract content/docs/automation/approvals.mdx already states ("An entry that resolves to nobody is not an error: the request opens with an empty pending_approvers"). No accepted input widens and no export is added.
Responsibility: this repository's plugin-approvals: resolveApproverSpec's empty-lookup fallback writes a literal manager:undefined slot that matches nobody | the node's onEmptyApprovers policy, which never fires while the literal holds the slate | any app whose record owners carry no manager_id; measured on 17.7.0 in objectstack-ai/hotclm
Thread-read: 6091880508
Serial constraints cleared:
{
"issue": 22558,
"status": "done",
"branch": "claude/issue-22558-empty-approver-no-slot",
"pr": "https://github.com/objectstack-ai/objectstack/pull/22583",
"session": "session_013j5gkUCpqQiti4GgPqqmnt — subagent run, the parent PM session id (harness CLAUDE_CODE_REMOTE_SESSION_ID cse_013j5gkUCpqQiti4GgPqqmnt)",
"premise_still_valid": true,
"summary": "A person approver (`manager`, `field`) that resolves to nobody now adds no slot: `resolveApproverSpec` returns [] for the new PERSON_APPROVER_TYPES before the `type:value` literal fallback, so a `{ type: 'manager' }` step for an owner with no `sys_user.manager_id` opens on an empty `pending_approvers` (was `[\"manager:undefined\"]`) and the node's onEmptyApprovers policy decides it; `manager` keeps a warning of its own (now says it adds no slot, names the subject). Premise measured on origin/main 6a3f82efa7: the stored `manager:undefined` slot HOLDS (reproduced by the ablation), but the card's sub-claim \"keeps onEmptyApprovers from firing\" does NOT: openNodeRequest already judges emptiness as \"no entry without a colon\", and fail/auto_approve/fallback were already pinned on the manager:undefined rung; the literal's real harm was the dead slot under admin_rescue and an unapprovable slot holding unanimous/quorum/per_group slates open. Per-type verdict: manager and field → no slot; user (value is the slot), expression resolveAs user (already []) unchanged; position KEPT (its literal is an acting address per approver-address.ts); team/department/org_membership_level/queue/expression-graph literals left unchanged and raised as an open question. Stored rows: 0 in this repo's seeds/fixtures (control 1); not rewritten - snapshot rows keep the dead slot and the admin override; pre-snapshot rows are re-evaluated at the next approve (both pinned).",
"tests": "At HEAD 49a690c1: `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` (via os-verify-lock) → \"Test Files 64 passed (64) / Tests 924 passed (924)\", VERDICT command-exit 0. `pnpm --filter @objectstack/plugin-approvals typecheck` → exit 0, \"check:test-typecheck: OK ... 8 file(s) / 324 error(s) / 27 pinned signature(s) held\" (VERDICT command-exit 0). Dependency closure built first: `pnpm turbo run build --filter='@objectstack/plugin-approvals^...' --concurrency=1` → 31/31 tasks, VERDICT 0; then the package itself (32/32, VERDICT 0). New pins (approval-service.test.ts, describe \"a person approver that resolves to nobody adds no slot (#22558)\"): owner with null manager_id → pending_approvers [] and the admin_rescue warning fires; same step under fallback → [u_backstop]; CONTROL owner with manager → [u_boss]; [manager, user u9] unanimous → [u9] and u9 finalizes; empty field → []; KEPT position → [position:cfo]; stored row WITH snapshot → not rewritten, admin override finalizes; stored row WITHOUT snapshot → re-evaluated, u9 finalizes. Plus a `manager` no-slot case in the #3807 describe. Ablation leg 1 (scripts/ablation-replace.mjs wrap mode, committed HEAD 49a690c1): anchor ` if (PERSON_APPROVER_TYPES.has(type)) {` → ` if (false && PERSON_APPROVER_TYPES.has(type)) {`, anchor 1→0, blob f95d8067dbae → 4a2a9ef66717; run → \"Tests 9 failed | 350 passed (359)\", key failure \"expected [ 'manager:undefined' ] to deeply equal []\"; restore: blob == HEAD f95d8067dbae, git diff HEAD empty. Ablation leg 2: `new Set(['manager', 'field'])` → `new Set(['manager'])`, blob → f1cf085ceb06; run → \"Tests 1 failed | 358 passed\" (stored row WITHOUT snapshot: expected false to be true on vote.finalized); restore proven the same way. Controls (manager slot, position literal, fallback) green under both legs. Tests import ./approval-service.js relatively (src), so no dist rebuild leg applies. Gates: 67 derived by `node scripts/pm/dispatch-gates.mjs --commands` at 49a690c1; 65 exit 0, 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads needs a whole-repo build; check:i18n needs the CLI closure and the plugin's extract config reads only the three sys-approval-*.object.ts files + translation bundles, none touched); `--ran` reconciliation: \"67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED\".",
"gates": [
"pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 :: exit 0",
"pnpm --filter @objectstack/plugin-approvals typecheck :: exit 0",
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-dts-emitted.mjs --self-test :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-issue-citations.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-tenant-audit-census.mjs :: exit 0",
"node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"node scripts/release-pending-publish.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET - NOT MEASURED)",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-status-conformance :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:i18n :: exit 3 (PREREQUISITE NOT MET - NOT MEASURED)",
"pnpm check:i18n-stale-fill :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0",
"node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.txt :: exit 0"
],
"files_changed": [
".changeset/22558-empty-approver-no-slot.md",
"packages/plugins/plugin-approvals/src/approval-service.ts",
"packages/plugins/plugin-approvals/src/approval-service.test.ts",
"packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts"
],
"line_budget": "362 changed lines (+326 / -36, 4 files) vs the 3000 human-merge threshold: under. No governed surface, no skills/** touched.",
"deviations": [
"Scope reading declared, not silently chosen: the triage's \"literal stays only for types whose literal IS the slot\" was applied to the person types (manager, field); group types (team, department, org_membership_level, queue, expression resolveAs team/department) keep their literal. Changing them would falsify approvals.mdx's admin-override callout, which this claim excludes from the file surface. See open_questions.",
"origin/main not merged before opening the PR (AGENTS.md Multi-agent section 10 asks for it): 6 commits landed since BASE 6a3f82efa7, none touches packages/plugins/plugin-approvals or content/docs/automation/approvals.mdx (git diff --stat empty); CI tests the merge ref.",
"Two derived gates NOT MEASURED (exit 3, prerequisite builds): check:dual-build-cjs-loads, check:i18n — declared narrowing, CI runs both.",
"Commit trailers use the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude); the harness reminder asked for a model-bearing Co-Authored-By, which AGENTS.md and the pre-push hook forbid.",
"The card's sub-claim \"keeps onEmptyApprovers from firing\" was falsified (see summary); the fix still lands because the stored dead slot and the multi-approver hold are real."
],
"mcp_calls": "0 — no MCP GitHub tool called.",
"api_writes": "3 — all through the fleet relay as objectstack-fleet[bot] (each preceded by its transport POST /repos/objectstack-ai/objectstack/dispatches as the session): (1) POST /repos/objectstack-ai/objectstack/pulls (pr_create draft → #22583, run 38016402795, body read back identical 12082 bytes); (2) POST /repos/objectstack-ai/objectstack/issues/22583/assignees (label-write --assign zhuangjianguo, run 38016426834, read back MATCHES); (3) POST /repos/objectstack-ai/objectstack/issues/22558/comments (this os-dev-report). Plus 5 git pushes (not REST). No label written (none named by the dispatch; skip-changeset does not apply).",
"open_questions": [
{
"question": "Should the GROUP approver types (team, department/business_unit/bu, org_membership_level, queue, and expression with resolveAs team/department) also stop leaving their TYPE:VALUE literal when they resolve to nobody, as a literal reading of the triage direction (\"the literal stays only for types whose literal IS the slot\" - only position qualifies) implies? This PR changes only the person types.",
"options": [
"A (this PR as is): person types add no slot; every group type keeps its literal. Cost: approvals.mdx stays internally inconsistent - its general sentence (\"an entry that resolves to nobody ... opens with an empty pending_approvers\") vs its admin-override callout (\"routed to a position / team / department with no holders resolves to only its literal\") - so a docs-only edit should narrow the general sentence to person entries.",
"B: every type except position adds no slot. Cost: reverses behaviour the 17.2.0 / 17.4.0 / 17.5.0 changelogs describe; needs a docs edit to the admin-override callout; and under unanimous / per_group an empty REQUIRED group (Finance AND Legal) would stop holding the request and approve without that group - a fail-open change to an approval control."
],
"recommendation": "A. Real business need: the measured report is the manager rung only; no report of a group literal causing harm, and onEmptyApprovers already fires on a literal-only slate. Long-term: B is more uniform, but A keeps a fail-closed group sign-off that B would silently weaken. AI-error prevention: A plus a docs-only narrowing of the general sentence makes the documented contract match the runtime for every type. Startup scope: A is the smaller change and adds no gate."
}
],
"out_of_scope_findings": [
"carrier: PM decision on the open question above (docs-only edit to content/docs/automation/approvals.mdx under option A) · noted, not filed · dedupe words: approvals.mdx empty pending_approvers group literal admin-override callout",
"carrier: 承接者:无 · `{ type: 'user' }` with no `value` (value is optional for every approver type in approval.zod.ts) would store the literal `undefined` as a CONCRETE slot (no colon), defeating the empty-slate test; no measured producer or public-door repro, so not class c · noted in Acceptance notes · dedupe words: user approver missing value undefined slot",
"carrier: 承接者:无 · lookupManager folds a sys_user read failure into \"no manager\" (pre-existing catch returning null); now also yields no slot, and the new warning names it as a possible cause; observation only · noted in Acceptance notes · dedupe words: lookupManager read failure manager_id null"
]
}
ACCEPT (seat review): PR #22583 at head 49a690c14a. A person approver that resolves to nobody adds no slot
domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T02:23Z. Claim 6092017325; read against GitHub and origin/main, not the report (os-dev-report on this card).
Review route.Clause-②: no. No packages/spec/src path, no governed text (check-governed-merges --pr 22583: NOT governed, 362 changed lines). So the review is a seat read plus CI, with no second agent.
Shape. Draft, base main. Line 1 is Fixes #22558, line 2 is Clause-②: no. A closing-keyword scan of the whole body finds only #22558. Four files, all in the claim's surface: approval-service.ts, its test, manager-approver-org-screen.test.ts, and .changeset/22558-empty-approver-no-slot.md (patch, @objectstack/plugin-approvals). Nothing under content/docs/releases/.
The change, as read in the diff
PERSON_APPROVER_TYPES = {manager, field}. resolveApproverSpec returns [] for them before the literal fallback.
manager leaves GRAPH_APPROVER_TYPES and keeps its own warning. The warning has no tracker number and names onEmptyApprovers: 'fallback' as the remedy.
position keeps its literal: it is an acting address (approver-address.ts). The other group types are unchanged.
The fallback branch's comment is corrected.
Evidence read
Eight new pins. Two ablation legs from a committed head, each with a blob proof and a restore proof:
the literal put back → 9 red, expected [ 'manager:undefined' ] to deeply equal [];
field removed from the set → 1 red.
Controls stayed green under both legs.
The fixture triage is declared row by row (replaced, or re-spelled, with the reason).
Stored rows: zero in this repo's seeds and fixtures, with a control. No rewrite: a row with a snapshot keeps its dead slot and the administrator override; a row without one is re-evaluated at its next approve. Both are pinned.
main moved 7 commits past the base 6a3f82efa7. git diff --stat 6a3f82efa7 origin/main -- packages/plugins/plugin-approvals content/docs/automation/approvals.mdx is empty, so not merging main before the PR is acceptable. CI tests the merge ref.
The dev's correction, acknowledged in public. The card's sub-claim "keeps onEmptyApprovers from firing" does not hold. openNodeRequest already counts a literal-only slate as empty, and fail, auto_approve and fallback were already pinned on the manager:undefined rung. The real harm was:
the dead slot left under admin_rescue;
an unapprovable slot holding a unanimous, quorum or per_group slate open.
The fix still lands, for those.
The open question, ruled in-seat: A (as built). Person types add no slot; the group types keep their type:value literal.
The governing text for the group types is the same page's admin-override callout (approvals.mdx about :651–:653), which documents the literal. The 17.2.0, 17.4.0 and 17.5.0 changelogs describe it too.
Option B would let an empty required group (unanimous, per_group) approve without that group. That is a fail-open change to an approval control, with no measured pull.
(2) { type: 'user' } without a value: no measured producer and no reach, so it stays in the PR's Acceptance notes and is not filed.
(3) lookupManager folds a failed read into "no manager": an observation, noted in the PR and in the new warning's text.
CI at 49a690c14a, read at this stamp: 16 success, 3 skipped, 12 in progress (Test Core, the Dogfood Regression Gate, Temporal Conformance, Lint & Repo Gates, Type Check · consumer gates). Two families the dev could not measure locally (check:dual-build-cjs-loads, check:i18n) run in CI. Landing waits for every check to be green, then goes ready and auto-merge through the queue.
What landed (@objectstack/plugin-approvals, patch): a manager or field approver that resolves to nobody adds no slot. A request left with an empty slate goes to the node's onEmptyApprovers policy, and a multi-approver slate is carried by its other entries. Group approvers keep their type:value literal, as the seat ruled in the ACCEPT. Stored requests are not rewritten.
Activity
objectstack-fleet commentedon Oct 10, 2026
Triage: first grade,
bug·priority:p2·domain:services·area:workflow·pm:queue. Direction: an approver that resolves to nobody contributes nothingTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-10T00:56Z. ⛔ Not a claim, ⛔ not a dispatch.Triage:
ApprovalService.expandApproversis inpackages/plugins/plugin-approvals. That puts it indomain:services.managerapprover resolves to nobody holds a literalmanager:undefinedslot. It matches no one, so the request is stuck pending, andonEmptyApprovers(spec: approvalonEmptyApproversgains'fallback'with a siblingfallbackApproversat the node level — the empty{ type: 'manager' }rung becomes survivable (from #16678 Phase 2 §8.2, ruled) #17931), the documented remedy, never fires. Any app whose users carry nomanager_idreaches it.expandApprovers, an approver of a user-resolving type (managerand the others that resolve to users) that resolves to nobody adds no slot.type:valuefallback stays only for types whose literal is the slot, if any exist. Name them in the PR.onEmptyApprovers, ascontent/docs/automation/approvals.mdxstates.…:undefinedslot. The PR states their disposition: re-evaluated on the next transition, or listed for the operator. ⛔ No silent rewrite.managerstep for an owner without amanager_idopens with an emptypending_approvers, andonEmptyApproversfires;objectstack-fleet commentedon Oct 10, 2026
Claim: PM loop round 1 · 2026-10-10T01:09Z
Session:
session_013j5gkUCpqQiti4GgPqqmntAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22558-empty-approver-no-slotWorktree:
objectstack-issue-22558Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface:
packages/plugins/plugin-approvals/only, read onorigin/main6a3f82efa7:approval-service.ts, the approver expansion region:expandApprovers(about:1747) andresolveApproverSpec(about:1816), where a spec that resolves to nobody falls back to the literaltype:valuestring. An approver of a user-resolving type that resolves to nobody adds no slot, so an empty set reaches the node'sonEmptyApproverspolicy. The literal stays only for a type whose literal is a slot a holder acts under (approver-address.ts); the PR names each kept type and why.plugin-approvals, covering triage's pins (6091880508)..changeset/22558-empty-approver-no-slot.md:patchfor@objectstack/plugin-approvals.packages/spec, nocontent/docs(the documented sentence is the contract this aligns to), no other package.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; the default tier, for an approval-routing judgement)Clause-②: no
content/docs/automation/approvals.mdxalready states ("An entry that resolves to nobody is not an error: the request opens with an emptypending_approvers"). No accepted input widens and no export is added.Responsibility:
this repository's plugin-approvals: resolveApproverSpec's empty-lookup fallback writes a literal manager:undefined slot that matches nobody | the node's onEmptyApprovers policy, which never fires while the literal holds the slate | any app whose record owners carry no manager_id; measured on 17.7.0 in objectstack-ai/hotclmThread-read: 6091880508
Serial constraints cleared:
sys_approval_requesthas no parent-record read gate on the data door, and the ruled record-reader tier cannot be switched on by an app (17.7.0) #22559 (this seat, dispatched in this round) adds a data-door read gate and reachesapproval-service.ts's visibility region (about:6582). Region-level only: whichever lands later mergesmain.sys_approval_request's detail page as a slotted page through the manifestpages, and declare the thread reply asapproval_comment(objectui#12045 B1, ruling 乙) #22473 (domain:servicesseat 2, in flight, no PR yet) adds a page module and an action inplugin-approvalsand readsapproval-service.ts. Region-level only.plugin-approvals(9 open non-release PRs read at their file lists, 2026-10-10T01:09Z).Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
os-dev-report
{ "issue": 22558, "status": "done", "branch": "claude/issue-22558-empty-approver-no-slot", "pr": "https://github.com/objectstack-ai/objectstack/pull/22583", "session": "session_013j5gkUCpqQiti4GgPqqmnt — subagent run, the parent PM session id (harness CLAUDE_CODE_REMOTE_SESSION_ID cse_013j5gkUCpqQiti4GgPqqmnt)", "premise_still_valid": true, "summary": "A person approver (`manager`, `field`) that resolves to nobody now adds no slot: `resolveApproverSpec` returns [] for the new PERSON_APPROVER_TYPES before the `type:value` literal fallback, so a `{ type: 'manager' }` step for an owner with no `sys_user.manager_id` opens on an empty `pending_approvers` (was `[\"manager:undefined\"]`) and the node's onEmptyApprovers policy decides it; `manager` keeps a warning of its own (now says it adds no slot, names the subject). Premise measured on origin/main 6a3f82efa7: the stored `manager:undefined` slot HOLDS (reproduced by the ablation), but the card's sub-claim \"keeps onEmptyApprovers from firing\" does NOT: openNodeRequest already judges emptiness as \"no entry without a colon\", and fail/auto_approve/fallback were already pinned on the manager:undefined rung; the literal's real harm was the dead slot under admin_rescue and an unapprovable slot holding unanimous/quorum/per_group slates open. Per-type verdict: manager and field → no slot; user (value is the slot), expression resolveAs user (already []) unchanged; position KEPT (its literal is an acting address per approver-address.ts); team/department/org_membership_level/queue/expression-graph literals left unchanged and raised as an open question. Stored rows: 0 in this repo's seeds/fixtures (control 1); not rewritten - snapshot rows keep the dead slot and the admin override; pre-snapshot rows are re-evaluated at the next approve (both pinned).", "tests": "At HEAD 49a690c1: `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` (via os-verify-lock) → \"Test Files 64 passed (64) / Tests 924 passed (924)\", VERDICT command-exit 0. `pnpm --filter @objectstack/plugin-approvals typecheck` → exit 0, \"check:test-typecheck: OK ... 8 file(s) / 324 error(s) / 27 pinned signature(s) held\" (VERDICT command-exit 0). Dependency closure built first: `pnpm turbo run build --filter='@objectstack/plugin-approvals^...' --concurrency=1` → 31/31 tasks, VERDICT 0; then the package itself (32/32, VERDICT 0). New pins (approval-service.test.ts, describe \"a person approver that resolves to nobody adds no slot (#22558)\"): owner with null manager_id → pending_approvers [] and the admin_rescue warning fires; same step under fallback → [u_backstop]; CONTROL owner with manager → [u_boss]; [manager, user u9] unanimous → [u9] and u9 finalizes; empty field → []; KEPT position → [position:cfo]; stored row WITH snapshot → not rewritten, admin override finalizes; stored row WITHOUT snapshot → re-evaluated, u9 finalizes. Plus a `manager` no-slot case in the #3807 describe. Ablation leg 1 (scripts/ablation-replace.mjs wrap mode, committed HEAD 49a690c1): anchor ` if (PERSON_APPROVER_TYPES.has(type)) {` → ` if (false && PERSON_APPROVER_TYPES.has(type)) {`, anchor 1→0, blob f95d8067dbae → 4a2a9ef66717; run → \"Tests 9 failed | 350 passed (359)\", key failure \"expected [ 'manager:undefined' ] to deeply equal []\"; restore: blob == HEAD f95d8067dbae, git diff HEAD empty. Ablation leg 2: `new Set(['manager', 'field'])` → `new Set(['manager'])`, blob → f1cf085ceb06; run → \"Tests 1 failed | 358 passed\" (stored row WITHOUT snapshot: expected false to be true on vote.finalized); restore proven the same way. Controls (manager slot, position literal, fallback) green under both legs. Tests import ./approval-service.js relatively (src), so no dist rebuild leg applies. Gates: 67 derived by `node scripts/pm/dispatch-gates.mjs --commands` at 49a690c1; 65 exit 0, 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads needs a whole-repo build; check:i18n needs the CLI closure and the plugin's extract config reads only the three sys-approval-*.object.ts files + translation bundles, none touched); `--ran` reconciliation: \"67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED\".", "gates": [ "pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 :: exit 0", "pnpm --filter @objectstack/plugin-approvals typecheck :: exit 0", "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0", "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0", "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0", "node scripts/check-changeset-no-major.mjs --self-test :: exit 0", "node scripts/check-ci-filter-parity.mjs :: exit 0", "node scripts/check-closing-keyword-parity.mjs :: exit 0", "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0", "node scripts/check-comment-mask-adoption.mjs :: exit 0", "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0", "node scripts/check-comment-mask-corpus.mjs :: exit 0", "node scripts/check-dts-emitted.mjs --self-test :: exit 0", "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0", "node scripts/check-empty-changeset.mjs --self-test :: exit 0", "node scripts/check-issue-citations.mjs :: exit 0", "node scripts/check-keyed-text-bounds.mjs :: exit 0", "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0", "node scripts/check-platform-object-tenancy-census.mjs :: exit 0", "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0", "node scripts/check-plugin-teardown-shape.mjs :: exit 0", "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0", "node scripts/check-registry-log-declared.mjs :: exit 0", "node scripts/check-registry-log-declared.mjs --self-test :: exit 0", "node scripts/check-rest-log-spy-declared.mjs :: exit 0", "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0", "node scripts/check-system-context-census.mjs :: exit 0", "node scripts/check-system-context-census.mjs --self-test :: exit 0", "node scripts/check-tenant-audit-census.mjs :: exit 0", "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0", "node scripts/check-undeclared-dep-imports.mjs :: exit 0", "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0", "node scripts/docs-audit/check-affected-docs.mjs :: exit 0", "node scripts/docs-audit/check-drift-comment.mjs :: exit 0", "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0", "node scripts/release-pending-publish.mjs --self-test :: exit 0", "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0", "pnpm check:changeset-gate-self-tests :: exit 0", "pnpm check:cross-package-test-inputs :: exit 0", "pnpm check:dispatcher-error-vocabulary :: exit 0", "pnpm check:doc-authoring :: exit 0", "pnpm check:driver-memory-census :: exit 0", "pnpm check:dts-closure :: exit 0", "pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET - NOT MEASURED)", "pnpm check:engine-double-contract :: exit 0", "pnpm check:error-status-conformance :: exit 0", "pnpm check:gitlink-declared :: exit 0", "pnpm check:i18n :: exit 3 (PREREQUISITE NOT MET - NOT MEASURED)", "pnpm check:i18n-stale-fill :: exit 0", "pnpm check:issue-citations :: exit 0", "pnpm check:lean-entry-closure :: exit 0", "pnpm check:logger-receiver-detach :: exit 0", "pnpm check:nul-bytes :: exit 0", "pnpm check:objectql-double-limit :: exit 0", "pnpm check:objectui-changeset :: exit 0", "pnpm check:org-identifier :: exit 0", "pnpm check:page-declaration-shape :: exit 0", "pnpm check:pm-changeset-deadline-census :: exit 0", "pnpm check:published-files :: exit 0", "pnpm check:query-options-erasure :: exit 0", "pnpm check:refd-timer-probe :: exit 0", "pnpm check:slot-lookup :: exit 0", "pnpm check:sourcemap-no-sources-content :: exit 0", "pnpm check:test-source-alias :: exit 0", "pnpm check:tier-file-adoption :: exit 0", "pnpm check:type-check-coverage :: exit 0", "pnpm check:type-check-debt :: exit 0", "pnpm check:watch-hint-literal :: exit 0", "pnpm check:where-matcher :: exit 0", "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.txt :: exit 0" ], "files_changed": [ ".changeset/22558-empty-approver-no-slot.md", "packages/plugins/plugin-approvals/src/approval-service.ts", "packages/plugins/plugin-approvals/src/approval-service.test.ts", "packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts" ], "line_budget": "362 changed lines (+326 / -36, 4 files) vs the 3000 human-merge threshold: under. No governed surface, no skills/** touched.", "deviations": [ "Scope reading declared, not silently chosen: the triage's \"literal stays only for types whose literal IS the slot\" was applied to the person types (manager, field); group types (team, department, org_membership_level, queue, expression resolveAs team/department) keep their literal. Changing them would falsify approvals.mdx's admin-override callout, which this claim excludes from the file surface. See open_questions.", "origin/main not merged before opening the PR (AGENTS.md Multi-agent section 10 asks for it): 6 commits landed since BASE 6a3f82efa7, none touches packages/plugins/plugin-approvals or content/docs/automation/approvals.mdx (git diff --stat empty); CI tests the merge ref.", "Two derived gates NOT MEASURED (exit 3, prerequisite builds): check:dual-build-cjs-loads, check:i18n — declared narrowing, CI runs both.", "Commit trailers use the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude); the harness reminder asked for a model-bearing Co-Authored-By, which AGENTS.md and the pre-push hook forbid.", "The card's sub-claim \"keeps onEmptyApprovers from firing\" was falsified (see summary); the fix still lands because the stored dead slot and the multi-approver hold are real." ], "mcp_calls": "0 — no MCP GitHub tool called.", "api_writes": "3 — all through the fleet relay as objectstack-fleet[bot] (each preceded by its transport POST /repos/objectstack-ai/objectstack/dispatches as the session): (1) POST /repos/objectstack-ai/objectstack/pulls (pr_create draft → #22583, run 38016402795, body read back identical 12082 bytes); (2) POST /repos/objectstack-ai/objectstack/issues/22583/assignees (label-write --assign zhuangjianguo, run 38016426834, read back MATCHES); (3) POST /repos/objectstack-ai/objectstack/issues/22558/comments (this os-dev-report). Plus 5 git pushes (not REST). No label written (none named by the dispatch; skip-changeset does not apply).", "open_questions": [ { "question": "Should the GROUP approver types (team, department/business_unit/bu, org_membership_level, queue, and expression with resolveAs team/department) also stop leaving their TYPE:VALUE literal when they resolve to nobody, as a literal reading of the triage direction (\"the literal stays only for types whose literal IS the slot\" - only position qualifies) implies? This PR changes only the person types.", "options": [ "A (this PR as is): person types add no slot; every group type keeps its literal. Cost: approvals.mdx stays internally inconsistent - its general sentence (\"an entry that resolves to nobody ... opens with an empty pending_approvers\") vs its admin-override callout (\"routed to a position / team / department with no holders resolves to only its literal\") - so a docs-only edit should narrow the general sentence to person entries.", "B: every type except position adds no slot. Cost: reverses behaviour the 17.2.0 / 17.4.0 / 17.5.0 changelogs describe; needs a docs edit to the admin-override callout; and under unanimous / per_group an empty REQUIRED group (Finance AND Legal) would stop holding the request and approve without that group - a fail-open change to an approval control." ], "recommendation": "A. Real business need: the measured report is the manager rung only; no report of a group literal causing harm, and onEmptyApprovers already fires on a literal-only slate. Long-term: B is more uniform, but A keeps a fail-closed group sign-off that B would silently weaken. AI-error prevention: A plus a docs-only narrowing of the general sentence makes the documented contract match the runtime for every type. Startup scope: A is the smaller change and adds no gate." } ], "out_of_scope_findings": [ "carrier: PM decision on the open question above (docs-only edit to content/docs/automation/approvals.mdx under option A) · noted, not filed · dedupe words: approvals.mdx empty pending_approvers group literal admin-override callout", "carrier: 承接者:无 · `{ type: 'user' }` with no `value` (value is optional for every approver type in approval.zod.ts) would store the literal `undefined` as a CONCRETE slot (no colon), defeating the empty-slate test; no measured producer or public-door repro, so not class c · noted in Acceptance notes · dedupe words: user approver missing value undefined slot", "carrier: 承接者:无 · lookupManager folds a sys_user read failure into \"no manager\" (pre-existing catch returning null); now also yields no slot, and the new warning names it as a possible cause; observation only · noted in Acceptance notes · dedupe words: lookupManager read failure manager_id null" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
ACCEPT (seat review): PR #22583 at head
49a690c14a. A person approver that resolves to nobody adds no slotdomain:servicesseat 1 (#6021) ·session_013j5gkUCpqQiti4GgPqqmnt· 2026-10-10T02:23Z. Claim6092017325; read against GitHub andorigin/main, not the report (os-dev-report on this card).Review route.
Clause-②: no. Nopackages/spec/srcpath, no governed text (check-governed-merges --pr 22583: NOT governed, 362 changed lines). So the review is a seat read plus CI, with no second agent.Shape. Draft, base
main. Line 1 isFixes #22558, line 2 isClause-②: no. A closing-keyword scan of the whole body finds only#22558. Four files, all in the claim's surface:approval-service.ts, its test,manager-approver-org-screen.test.ts, and.changeset/22558-empty-approver-no-slot.md(patch,@objectstack/plugin-approvals). Nothing undercontent/docs/releases/.The change, as read in the diff
PERSON_APPROVER_TYPES = {manager, field}.resolveApproverSpecreturns[]for them before the literal fallback.managerleavesGRAPH_APPROVER_TYPESand keeps its own warning. The warning has no tracker number and namesonEmptyApprovers: 'fallback'as the remedy.positionkeeps its literal: it is an acting address (approver-address.ts). The other group types are unchanged.fallbackbranch's comment is corrected.Evidence read
expected [ 'manager:undefined' ] to deeply equal [];fieldremoved from the set → 1 red.Controls stayed green under both legs.
mainmoved 7 commits past the base6a3f82efa7.git diff --stat 6a3f82efa7 origin/main -- packages/plugins/plugin-approvals content/docs/automation/approvals.mdxis empty, so not mergingmainbefore the PR is acceptable. CI tests the merge ref.The dev's correction, acknowledged in public. The card's sub-claim "keeps
onEmptyApproversfrom firing" does not hold.openNodeRequestalready counts a literal-only slate as empty, andfail,auto_approveandfallbackwere already pinned on themanager:undefinedrung. The real harm was:admin_rescue;unanimous,quorumorper_groupslate open.The fix still lands, for those.
The open question, ruled in-seat: A (as built). Person types add no slot; the group types keep their
type:valueliteral.approvals.mdxabout:651–:653), which documents the literal. The 17.2.0, 17.4.0 and 17.5.0 changelogs describe it too.unanimous,per_group) approve without that group. That is a fail-open change to an approval control, with no measured pull.:384–:386, "the request opens with an emptypending_approvers… parks forever") was already wrong for the group types. It is filed as a docs-only card: docs(approvals):approvals.mdxsays an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep theirtype:valueliteral, and every empty slate reachesonEmptyApprovers#22584.Out-of-scope findings, dispositions.
approvals.mdxsays an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep theirtype:valueliteral, and every empty slate reachesonEmptyApprovers#22584.{ type: 'user' }without avalue: no measured producer and no reach, so it stays in the PR's Acceptance notes and is not filed.lookupManagerfolds a failed read into "no manager": an observation, noted in the PR and in the new warning's text.CI at
49a690c14a, read at this stamp: 16 success, 3 skipped, 12 in progress (Test Core, the Dogfood Regression Gate, Temporal Conformance, Lint & Repo Gates, Type Check · consumer gates). Two families the dev could not measure locally (check:dual-build-cjs-loads,check:i18n) run in CI. Landing waits for every check to be green, then goes ready and auto-merge through the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
Landed ·
domain:servicesseat 1 (#6021) ·session_013j5gkUCpqQiti4GgPqqmnt· 2026-10-10T02:59Zmanager:undefined#22583 merged through the queue asb98e984434, read onorigin/main(PERSON_APPROVER_TYPESpresent inapproval-service.ts). ItsFixes #22558closed this cardcompleted. In this actpm:dispatchedand the assignee were cleared; the closing keyword does not clean the board.@objectstack/plugin-approvals,patch): amanagerorfieldapprover that resolves to nobody adds no slot. A request left with an empty slate goes to the node'sonEmptyApproverspolicy, and a multi-approver slate is carried by its other entries. Group approvers keep theirtype:valueliteral, as the seat ruled in the ACCEPT. Stored requests are not rewritten.approvals.mdxsays an approver entry that resolves to nobody opens an empty slate that "parks forever"; group entries keep theirtype:valueliteral, and every empty slate reachesonEmptyApprovers#22584 (docs only).Generated by Claude Code