Skip to content

docs(approvals): an entry that resolves to nobody leaves a slot per its kind, and an empty or literal-only slate goes to onEmptyApprovers - #22657

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22584-approvals-empty-slate-docs
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22584-approvals-empty-slate-docs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22584
Clause-②: no

The lifecycle step "The request opens, the run pauses" in content/docs/automation/approvals.mdx said:

An entry that resolves to nobody is not an error: the request opens with an empty pending_approvers and nothing can move it, so the run parks forever.

The runtime does neither half of that. This PR replaces the sentence with a statement per approver kind, following the triage direction on the card (6093078259, confirmed on unlock by 6093154668). Docs only, with no runtime change: the group types keeping their literal is the seat's ruling on #22558.

The new text (:388–:406 at a35518bce)

What an entry that resolves to nobody leaves on the slate depends on its kind:

  • A person entry (manager, field) adds no slot. An expression that yields no ids adds none either. A user entry never resolves to nobody, because its value is the slot.
  • A group entry (position, team, department, org_membership_level) keeps its type:value literal as a slot. So do a queue entry and an expression value that resolveAs re-expands into a group with nobody in it.
    • A position:NAME slot is decided by whoever is staffed into that position later.
    • No person ever holds any other literal, so only an administrator can decide it. This bullet links to the admin-override callout under "Acting on requests in the console".
    • Beside entries that do resolve, a literal is a slot like any other, so a behavior that needs every slot (unanimous) waits on it too.
  • A slate left empty, or holding only literals, goes to the node's onEmptyApprovers policy. This bullet links to where the policy's four values are listed. Under the default, admin_rescue, the request opens on that slate and waits for an administrator, or for a holder of a position: literal on it.

The page writes the placeholders as angle-bracket names inside code spans, as it already does elsewhere. This body writes them as type:value and position:NAME because GitHub's body sanitizer drops angle-bracket fragments. The rewrite links to the page's own onEmptyApprovers text and admin-override callout instead of repeating them.

What the runtime does, read at origin/main cc305a3cf

cc305a3cf is this branch's base, and b98e984434 (the commit the direction names) is its ancestor: git merge-base --is-ancestor b98e984434 HEAD exits 0. Every quote below is from packages/plugins/plugin-approvals/src/approval-service.ts unless another file is named.

  • Person kinds add no slot.
    • :958 const PERSON_APPROVER_TYPES = new Set(['manager', 'field']); (the generic type annotation is left out here).
    • :2071 if (PERSON_APPROVER_TYPES.has(type)) { … return [];
  • user never falls back. :1995 if (type === 'user') { returns its value through OOO delegation.
  • Every other kind falls through to its literal, including queue: :2117 return [`${type}:${a.value}`];
  • expression. An empty result normalizes to raw = [] and adds no slot. A resolveAs value whose expansion is empty keeps a literal: :2258 slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
  • The empty test counts literals as empty.
    • :3092 if (!approvers.some(a => a && !a.includes(':'))) {
    • :3093 const emptyPolicy = (input.config as any).onEmptyApprovers ?? 'admin_rescue';
    • fail throws NO_APPROVERS (:3094).
    • auto_approve opens nothing (:3101).
    • fallback replaces the slate when the fallback resolves to a concrete person, and otherwise degrades to admin_rescue (:3119).
    • admin_rescue opens with pending_approvers: approvers.join(','), (:3237).
  • Who can take a literal.
    • approver-address.ts:64 const POSITION_ADDRESS_PREFIXES = ['position:'] as const;
    • The acting addresses are the user id, the account's own email, and the position: address of a held position (actingAddresses, :116).
    • A team:, department:, org_membership_level: or queue: slot is held by no one. Only the admin override decides it.
  • The tally. :3366 return original.length > 0 && original.every(a => approved.has(a)); So a literal on a unanimous slate must be approved too, by a staffed holder or by the admin override.

PM mechanism assumptions 1 and 2 held. The one drift is a line number: the admin-override callout was at :667–:679 on the base, not :651–:653, because PR #22625 added lines above it. It is now at :685–:697.

The other line changed on the page

  • :441–:442. "position:finance_manager — the form an entry falls back to when it resolves to no users" now reads "a group entry". Since b98e984434 a person entry no longer falls back to a literal, so the rewrite above makes "an entry" false.

No other line on the page is made false by the rewrite. I read these and left them alone:

  • The manager callout (:100–:103) and the notification callout (now :480–:483) describe the default policy correctly.
  • The admin-override callout is accurate. It names position, team and department and leaves out org_membership_level. The rewrite and the role callout (:63–:67) cover that type.

Grep: no other hand-written page repeats "parks forever" or the empty-slate claim

At a35518bce:

$ git grep -n -o -i "parks forever"
content/docs/references/api/automation-api.mdx:157:parks forever
content/docs/references/automation/control-flow.mdx:123:parks forever
content/docs/references/automation/control-flow.mdx:186:parks forever
content/docs/references/automation/flow.mdx:78:parks forever
content/docs/references/automation/flow.mdx:149:parks forever
packages/spec/src/automation/flow.zod.ts:831:parks forever

All six hits are one claim. It is the waitEventConfig describe at packages/spec/src/automation/flow.zod.ts:831, which content/docs/references/** generates five rows from. It is about a wait node declared without waitEventConfig, which is a different claim and true of a different node. The references tree is generated, so it is not hand-edited here.

The second grep looks for the empty-slate claim itself across the hand-written trees, with the generated references excluded:

$ git grep -c -i -E "nothing can move it|empty .?pending_approvers|parks? (forever|indefinitely)" -- content/docs skills docs ":!content/docs/references/**"
(no output, exit 1)

control leg: the same grep at the base cc305a3cf
cc305a3cf:content/docs/automation/approvals.mdx:2      (exit 0)

Two nearby sentences are not this claim:

  • content/docs/capabilities/approvals.mdx:20 says a manager-only step "resolves to nobody and the request waits". That is true under the default policy. A different problem on that page is in the Acceptance notes.
  • skills/objectstack-automation/references/state-machines-and-approvals.md:351 describes onEmptyApprovers correctly.

Verification (final HEAD a35518bce)

  • Derived gates. I ran node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths. It took the change set from merge base cc305a3cf (1 path) and derived 40 commands, the same 40 the dispatch named.
    • All 40 ran on a35518bce, and each exited 0.
    • The --ran reconciliation printed "40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN".
    • On the first pass, at 29420c9e6, four gates exited 3 (PREREQUISITE NOT MET). @objectstack/lint and @objectstack/client-react were not built. After those named builds the four went green, and then all 40 re-ran on the final HEAD.
  • check:doc-anchors reads the new in-page link.
    • I used scripts/ablation-replace.mjs to change the anchor to #acting-on-requests-in-the-consolex (anchor hits 1 → 0, blob b331cd3cea3f → 54bbb25e11d3), at committed HEAD 29420c9e6.
    • The gate went red: content/docs/automation/approvals.mdx:399 #acting-on-requests-in-the-consolex … renders no heading with id "acting-on-requests-in-the-consolex".
    • Restore: the blob equals HEAD's (b331cd3cea3f) and git diff HEAD is empty.
  • pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2 (the spec project that scans content/docs) exited 0 with 54 files / 915 tests passed.
    • It started on 29420c9e6, and the a35518bce edit landed while it ran. That edit changes one phrase in one bullet ("listed at the end of" → "whose four values are listed under").
    • It was not re-run on the final HEAD. This is a declared narrowing: the 40-gate union above did re-run there.
  • Not run locally: CI's Build Docs job and the Test Core shards, which are path-scheduled and run in CI's own shell, and the repo-wide lint. Those runs belong to CI.

Acceptance notes

  • content/docs/capabilities/approvals.mdx:17–:18 is stale. It says "no Console field, data-API write or auth admin endpoint sets that column" (sys_user.manager_id). But POST /api/v1/auth/admin/set-user-manager exists (packages/platform-objects/src/identity/sys-user.object.ts:378, the set_user_manager row action), and this PR's own page documents it.
    • This is outside this claim's file surface, and it is a different defect from this card's. It is reported to the seat, not fixed here.
  • :376 says org_membership_level "silently resolves to nobody". The runtime now warns when a graph type expands to nobody, and os lint flags a position name authored there, so "silently" is soft. The rewrite does not make it false, so it is unchanged. This is an observation only.
  • The onEmptyApprovers text sits inside "Approving across organizations", although it is not about cross-org routing. The new link names that section. The text is not moved.

Generated by Claude Code

…its kind, and onEmptyApprovers decides an empty one

The lifecycle step said an entry that resolves to nobody opens an empty
pending_approvers that nothing can move, so the run parks forever. The
runtime does neither: a person entry (manager, field) adds no slot, a
group entry keeps its type:value literal (a position literal decided by a
later holder, any other only by an administrator), and an empty or
literal-only slate goes to the node's onEmptyApprovers policy
(admin_rescue by default). The queue-filter note now says a GROUP entry
falls back to the literal, which the rewrite made the only true reading.

Claude-Session: https://claude.ai/code/session_01Q7Fy4uVkvBWgj9CLihdATJ
Co-authored-by: Claude <noreply@anthropic.com>
…listed, not "the end" of that section

Claude-Session: https://claude.ai/code/session_01Q7Fy4uVkvBWgj9CLihdATJ
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants