Skip to content

Commit 69d4218

Browse files
feat(plugin-approvals): an empty admin_rescue slate opens on the organization's administrators; retire the unstaffed arm (#22850)
Fixes #22824 Clause-②: yes An approval node whose approvers resolve to no concrete person now opens, under the default `onEmptyApprovers: 'admin_rescue'`, on the request organization's administrators, as ordinary approvers in the ordinary inbox. The `unstaffed` queue arm that #22775 added (never published) retires in the same stroke, since the named slate makes it obsolete. Route P1, as ruled (maintainer 「同意 P1」, recorded as `6108375797` on objectstack-ai/hotcrm#2057); triage `6108769161`; claim `6108865702`. ## FROM → TO: who receives an `admin_rescue` request - **FROM:** the request opened on a slate that named no one (the literal `position:NAME`). Measured on this branch's base `efcbac73c`, on a real boot (`bootStack`, the seeded admin owning the default organization): the stored slate was `["position:unstaffed_arm_nobody"]`, the admin's My Pending (`status=pending&approverId=ADMIN_ID,EMAIL`) answered `data: [], total: 0`, their `GET /notifications?type=approval.requested` answered `notifications: [], unreadCount: 0`, and no `sys_inbox_message` row was written. Only the #3424 override could decide it. - **TO:** the request opens on the holders of the `TENANT_ADMIN` rung of the request's organization: the same authority the tenant arm of `isOverrideActor` trusts (ADR-0095 D3, `ORGANIZATION_ADMIN_GRANTS`), resolved to people. There is no second notion of "admin". ## What changed **`@objectstack/plugin-approvals`** (`approval-service.ts`) - `organizationAdministrators(org)`: the candidates are the organization's members (`sys_member.organization_id`, this file's existing answer to "who is in this organization"). Each is asked the rung question over the grants `resolveUserAuthzGrants` (`@objectstack/core`) resolves for them with that organization as the active tenant: the resolver every authorization envelope comes from, so grant windows, deactivated sets, other organizations' scoped grants and position-bound sets answer exactly as when the person acts. No `plugin-security` edit; no better-auth role is read as authority. - `holdsTenantAdminRung(permissions)` is now the one spelling of the grant read, shared by the tenant arm of `isOverrideActor` (a mechanical swap of the identical expression; its authority does not change) and by the slate. - Platform administrators are not added for being platform administrators. One who also holds the organization's rung (its owner, say) is on the slate for that rung. - At node entry, an empty slate under `admin_rescue` (and a `fallback` that found no one either, which already degraded to `admin_rescue`) goes through the SAME `expandApprovers` as `approvers`, as `user` entries in one synthetic group (`#0`). So OOO delegation, the `approverId` index, the `approval.requested` fan-out and `decide` apply unchanged, and a decision by one of them is a slot decision recorded with `via_override: false`. - The request says why. The abandoned slate and the reason are kept in `__resolvedFrom` as `admin_rescue:abandoned` / `admin_rescue:reason` (the `fallback` branch's flat, policy-prefixed shape), and the timeline carries one system note (`action: 'comment'`, `actor_id: null`, the shape the OOO hop row already uses), for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder." - Break-glass stays break-glass. An organization with no administrator, a request with no organization, or an administrator read that fails opens on the slate that names no one, logged as a warning that names which of the three and the fix. A read failure answers a failure, never an invented "nobody". - The `unstaffed` arm retires: `awaitingRequestIds` and `unstaffedOverridableRequestIds` are removed, and `listRequests` / `countRequests` are byte-identical to their pre-#22775 bodies (compared function by function). `slateNamesAPerson` stays (the open path reads it); its docblock no longer names the arm. **`@objectstack/spec`**: `IApprovalService.listRequests`' `unstaffed?: boolean` member retires with its docblock. The `limit` docblock keeps #22775's correction minus the arm. The `onEmptyApprovers` `.describe()` text and TSDoc say that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. `content/docs/references/automation/approval.mdx` is regenerated (`check:generated --fix`, `check:docs` only). **`@objectstack/rest`**: `unstaffed` leaves `APPROVAL_REQUEST_LIST_PARAMS` and the multiplicity set, and `readDeclaredQueryBoolean` (its only reader) goes. The file equals the exact reverse-apply of #22775's hunks onto `efcbac73c` (`git apply -R`, then `cmp`). `?unstaffed=` is now refused as an unknown name, `400` with nested `error.code: VALIDATION_ERROR`, before the service runs. **`@objectstack/lint`**: `approval-approvers-may-resolve-empty` no longer says "waits forever". Both arms say the request opens on the organization's administrators. The hints name `onEmptyApprovers: 'fallback'` as the way to name who decides, and the `os explain` paragraph says the same. Severity, firing conditions and rule ids are unchanged. **Docs** (`approvals.mdx`): a new "When a slate names no one" section. The manager callout, the `onEmptyApprovers` list, the fallback text, the notification callout and the admin-override callout now describe the administrators' slate. The `unstaffed` filter paragraph and curl are gone. ## The census, first No shipped consumer of the `unstaffed` member or `?unstaffed` exists outside #22775's own files: - this repository at `efcbac73c`: every code hit of `unstaffed:`, `?unstaffed`, `.unstaffed` and `'unstaffed'` is in #22775's file list. The only non-test callers of `listRequests` / `countRequests` are the REST handler's two calls; - objectstack-ai/objectui `origin/main` `1d1cfe0` (read only): `approvalsApi.ts` builds the list query from `status / object / recordId / approverId / submitterId / q / limit / offset` and never sends `unstaffed`. Every other `unstaffed` hit there is the console's own seat label (`approverIdentity.ts`'s `unstaffed: boolean` chip state), not the filter. - Not published: `npm pack @objectstack/{spec,rest,plugin-approvals}@17.7.0` (npm `latest` is `17.7.0`) holds 0 files matching `unstaffed?: boolean | unstaffedOverridableRequestIds | readDeclaredQueryBoolean`; control: each tarball holds 3–5 files naming `listRequests`. ## Mechanism assumptions, measured 1. **Holds.** See FROM above: the real boot at `efcbac73c` stored literals only, listed and counted nothing for the admin, and notified no one. 2. **Partly.** `ORGANIZATION_ADMIN_GRANTS` is declared in `packages/spec/src/identity/eval-user.zod.ts`. NO reader resolves "the users holding that rung in organization X": every reader is per user (`resolveUserAuthzGrants`, `derivePosture`, the explain engine). So the slate composes the per-user resolver over the organization's members. Nothing in `plugin-security` was edited. 3. **Partly false.** The `fallback` branch keeps the primary slate's resolution INPUTS (`field:NAME`, `expression#N`) and namespaces its own as `fallback:KEY`. It keeps neither the abandoned slate nor a reason, and a position-only slate writes nothing there (measured: `__resolvedFrom` absent on the boot above). The new keys mirror that flat, prefixed shape (`admin_rescue:abandoned`, `admin_rescue:reason`); there is no second snapshot shape. 4. **Holds.** The member never shipped (the tarball reading above). `check:adr-0087-registration --base origin/main`: exit 0. The new changeset declares no breaking arm, so no disposition marker is owed, and the deleted 22725 row is not a judged status. See Changesets for the one gate that stays red. ## Pins `src/admin-rescue-named-slate.integration.test.ts`: the real `ApprovalService` on a real `ObjectQL` over `SqlDriver` (better-sqlite3), the real `resolveUserAuthzGrants` over a bound security catalog, and the real `listRequests` / `countRequests` / `getRequest` / `decide` / messaging fan-out: - a position nobody holds opens on the two administrators (`organization_admin`, `organization_admin_no_bypass`), each one's My Pending lists it with `total: 1` and `viewer.can_act: true`, and each is told once; - the scene anchored on that slate: a member sees nothing and is told nothing, and a platform admin (unscoped `admin_full_access`) and another organization's administrator are not on it; - a tenant administrator's decision: `finalized: true`, `approved`, `acted_as` their id, `via_override` false; - the timeline note and `__resolvedFrom` (exact keys and reason); - a `fallback` that resolves to no one degrades to the administrators and says so in the reason; - an administrator out of office is covered by their delegate (`approval.ooo_substituted`), and is not told twice; - CONTROLS: a staffed slate is unchanged (no administrator, no note); `fallback` opens on its own list; `auto_approve` opens nothing and `fail` refuses (`NO_APPROVERS`); an organization with no administrator gets the slate that names no one, logs it, tells no one, and the platform admin's decision there is an override (`via_override` true). `packages/qa/dogfood/test/approval-admin-rescue-slate.dogfood.test.ts` (the #22775 dogfood pin, rewritten to the named slate) on a real boot, through the REST door: the seeded admin owns the default organization, and a second member is promoted by a `sys_member` role write, which provisions their capability. Both administrators find the request in My Pending (`total: 1`, `can_act: true`) and hold one `approval.requested` notification. A member sees and holds nothing. The staffed control is unchanged. An administrator's `POST …/approve` is an approval with `via_override` false and the routing note on the timeline. `?unstaffed=true` answers `400 VALIDATION_ERROR`. **Retired with the code they pinned**, ⛔ none skipped or disabled: the 9-case `ApprovalService — the unstaffed arm (#22725)` block in `approval-service.test.ts` (the file equals the exact reverse-apply of #22775's hunk), the REST §4 `unstaffed` block (replaced by one refusal pin: `?unstaffed=true|false` answers the full ADR-0112 unknown-name refusal and neither service method runs), and the dogfood arm pin (rewritten above). ## Ablation (one-time, from committed `f3ff683a1`) `node scripts/ablation-replace.mjs` replaced `if (administrators.length) {` with `if (false && administrators.length) {` (anchor 1 → 0, blob `259e3e834b00` → `551f2027d03b`), wrapped in a `trap` restoring `git checkout HEAD --` on an absolute path. The integration file then had **6 failed / 4 passed**: every named-slate case red, and the four controls (staffed, fallback, auto_approve / fail, and the leaderless organization with its override) green. Restore proven: blob `259e3e834b00` equals HEAD's, and `git diff HEAD` is 0 bytes. The integration test imports the service from `src`, so no `dist` hop applies. The dogfood pin was not ablated (it reads `plugin-approvals` through `dist`). ## Suites (final runs at `06f9948c8` unless noted) - `@objectstack/plugin-approvals`: `test` 73 files / 1035 passed; `typecheck` exit 0 (`check:test-typecheck` OK, the test layer included). - dogfood, 12 approval-reading files (the new pin, override-composite, my-pending, position-address-readers, flow-node-config, notification-body, activity-text-predicate, both snapshot pins, lookup-title-exposure, seed-ownership-claim-dispatch, audit-log-admin-search): 12 files / 43 passed. `dogfood` `typecheck` exit 0, and its `tsc --listFiles` lists the new pin and fixtures. - `@objectstack/rest` (at `23a63a18a`; `rest` is unchanged since): `test` 272 files / 5239 passed, 326 skipped; `typecheck` exit 0. - `@objectstack/lint` (at `23a63a18a`; unchanged since): `test` 137 files / 6416 passed; `typecheck` exit 0. - `@objectstack/spec` (at `f3ff683a1`; unchanged since): `typecheck` exit 0; `test` 648 files / 19342 passed; `check:generated` all artifacts up to date after the one `gen:docs` regeneration. - The Dogfood Regression Gate's full population is left to CI. ## Gates (at `06f9948c8`, the final commit) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **116** families (32 paths against merge base `efcbac73c`). Each was run with its exit code recorded to disk before any pipe: **115 exit 0, 1 exit 1**. The one red is `check-empty-changeset.mjs --base origin/main`, the deliberate correction below. `--ran` reconciliation: "116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN". - Two families first answered `PREREQUISITE NOT MET` (exit 3) on unbuilt packages: `check:skill-examples` (client-react) and `check:dual-build-cjs-loads`. After those packages were built, both exit 0 in the final run. In an earlier run, `check:query-options-erasure` caught two `as any` option erasures this branch had added, and `check-tenant-audit-census` caught the new write call site. Both were fixed (commits `06f9948c8`, `8af84ef80`) and both exit 0 in the final run. - Lint, narrowed and proven: eslint's population is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (`eslint.config.mjs`). `eslint --no-inline-config --format json` over the 24 changed TypeScript files reports 24 files, 0 errors, 0 warnings. The config sets no `parserOptions.project` (no type-aware linting), so this diff cannot move any untouched file's verdict. The Markdown and MDX files are outside eslint's population. - The 49 artifact-roster families, the CI-only jobs and the type-check lanes are left to CI. ## Changesets, and one deliberately red check - `.changeset/22824-admin-rescue-named-slate.md`: `@objectstack/plugin-approvals` minor, `@objectstack/spec` patch (description text), `@objectstack/lint` patch (verdict text). `Clause-②: yes`, stating FROM → TO. `@objectstack/rest` is not listed: against the published 17.7.0, its shipped content does not move. - **`.changeset/22725-approvals-unstaffed-arm.md` is withdrawn (deleted).** It announces an `unstaffed` arm that this PR retires before it ever ships, so it would publish a false release note. This is the DELIBERATE CORRECTION class `check-empty-changeset.mjs` names. `check:empty-changeset --base origin/main` stays **red by design**: Check Changeset is not a required context, and per ruling D the `skip-changeset` label is not applied. **Confirmation requested here:** the 22725 note is withdrawn because the behaviour it describes does not ship. ## File surface beyond the claim's list (declared) My change turned these red, so each is updated to keep what it pins: - `packages/lint/src/rule-explanations.ts` (the rule's `os explain` paragraph) and the rule's hints in `validate-approval-approvers.ts`. The dispatch ruled "the message text only"; the explanation and hints said "waits forever" / "recoverable only by override", which this change makes false, and the os-dev standard clause makes a text this change falsifies a must-fix. The rule's logic is untouched. The conflict is named in the report, not silently resolved. - `approval-requested-notification.integration.test.ts`: one test name and the header now say "with no organization administrator". - Dogfood pins whose scene was a slate the administrators' slate now replaces: - `approval-override-composite-pin` + fixture route to a named approver who never acts (the override's remaining break-glass case); - `my-pending-position-address` and `position-address-readers` + fixtures add a named co-approver, so the `position:NAME` literal stays on the slate for the late holder; - `flow-node-config-values-at-registration` asserts the administrators' slate; - `activity-text-predicate` and both approval-snapshot pins stamp the holder's organization on the `sys_user_position` rows they seed. The routing read never resolved an unstamped (unknown-tenancy) row, and the literal fallback had masked that. - `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`: the routing note's insert is one more write call site (238 → 239, decidable 159 → 160, elevated 126 → 127). Regenerated with `node scripts/tenant-audit-census.mjs --write`, and the census-held prose figures were updated by hand. ## Acceptance notes - **The abandoned slate is replaced, not kept beside the administrators**, as the ruling's "abandoned primary slate" and the `fallback` branch do. A holder staffed into a position after an all-literal request opened no longer receives it (an administrator can reassign it). A `position:NAME` literal beside a named approver is untouched, and so is the late holder's path to it. - **Unstamped position assignments.** `expandPositionUsers` matches `sys_user_position.organization_id` by strict equality (the platform's stated fail-closed reading of unknown tenancy, shared with `plugin-sharing`), so an organization-less assignment row never put its holder on a slate. Before, the literal stayed and that holder could still act through the `position:NAME` address. Now an all-literal slate opens on the administrators. Rows written through a door are stamped, and the showcase seed stamps them. Noted, not filed; carrier: none. - **Cost.** One grant resolution per member of the request's organization, eight at a time, on the empty-slate path only. - **The submitter is not excluded** from the administrators' slate (a one-admin organization decides its own request), the same as any slate that names its submitter. - `docs/qa/platform-checklist/areas/approvals.json` (the empty-slate fork item) still says `admin_rescue` is "decidable only via the #3424 privileged override". It is internal QA text, not a published surface; carrier: none. - The `ApproverType` describe text ("a manager step resolves to nobody and the request waits") is still literally true (it waits on the administrators). It was left alone under the describe-only scope. ## Seat append: patch round 1 (the dev's note, appended by the `domain:services` seat 1; the dev never edits this body) - `origin/main` `d48931871` was merged in by merge commit `9152dfdf5`, made by `scripts/pm/os-regen-merge.sh` (no rebase, no amend, no force-push). - The census conflict took `main`'s bytes in the merge. `9e24d7812` then regenerates both census files with `scripts/tenant-audit-census.mjs --write` and re-holds the page's prose figures (233 → 234: this branch's one new write call site). - **Gates at `9e24d7812`:** 116 derived, 116 run. The one expected red is `check-empty-changeset`, the deletion of the `22725` note, which the maintainer confirmed in `6109850105`. - **Tests:** `plugin-approvals` 1035/1035, lint 6455/6455, approval dogfood 43/43. ## Seat append: patch round 2 (the dev's note, appended by the `domain:services` seat 1) - The merge queue dequeued this PR at 2026-10-11T15:18Z (`MERGE_CONFLICT`, the same two census files). - `origin/main` `6364375b5` was merged in by merge commit `5d0a4a152` (`os-regen-merge.sh`), and `e25e6c27f` regenerates both census files with `scripts/tenant-audit-census.mjs --write` (231 → 232: this branch's one write call site). - **Gates at `e25e6c27f`:** 116 derived, 116 run. The one expected red is `check-empty-changeset`, the `22725` deletion confirmed in `6109850105`. - **Tests:** `plugin-approvals` 1035/1035, lint 6461/6461, approval dogfood 43/43, on dists rebuilt from the merged tree (which carries #22854 and #22819). - **The earlier contract review PASS (`6110255223`, on `9e24d7812e`) does NOT carry to this head.** The hop moved paths this PR owns that are not `merge=os-regen` regenerations: the two census files, plus `rest-server.ts` and `rule-explanations.ts` from `main`'s side (`unexplainedPathsBetween`, machine-read on the committed trees). So a fresh at-tier review runs on `e25e6c27f`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8532783 commit 69d4218

31 files changed

Lines changed: 1051 additions & 801 deletions

‎.changeset/22725-approvals-unstaffed-arm.md‎

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-approvals': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/lint': patch
5+
---
6+
7+
feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox
8+
9+
Clause-②: yes
10+
11+
**FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`:
12+
13+
- **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override.
14+
- **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically.
15+
16+
They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that.
17+
18+
The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them.
19+
20+
**Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning.
21+
22+
**To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list.
23+
24+
**`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead.
25+
26+
**`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same.

‎content/docs/automation/approvals.mdx‎

Lines changed: 72 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,14 @@ through a provisioning path your own deployment supplies: this platform declares
104104
surface that authors its manager.
105105

106106
Setting the column is still something you have to *do*. Where `manager_id` is unset the rung
107-
expands to nobody, the request opens on a slate no one can act on, and under the default
108-
`lockRecord: true` the record stays locked — recoverable only by a platform or tenant admin
109-
override. So set it for everyone who submits this request, or take one of the two escapes that
110-
need no write to the column at all: add a second approver entry that cannot resolve empty — e.g.
111-
`{ type: 'org_membership_level', value: 'owner' }` — or declare `onEmptyApprovers: 'fallback'`
112-
on the node with a `fallbackApprovers` list, so an empty manager rung opens the request on those
113-
people instead. Neither makes `manager` resolve; they make the failure survivable, which is a
107+
expands to nobody, and under the default `onEmptyApprovers: 'admin_rescue'` the request opens on
108+
the organization's administrators instead of on the submitter's manager
109+
([When a slate names no one](#when-a-slate-names-no-one)). So set it for everyone who submits this
110+
request, or take one of the two escapes that need no write to the column at all: add a second
111+
approver entry that cannot resolve empty — e.g. `{ type: 'org_membership_level', value: 'owner' }`
112+
— or declare `onEmptyApprovers: 'fallback'` on the node with a `fallbackApprovers` list, so an
113+
empty manager rung opens the request on those people instead of on the administrators. Neither
114+
makes `manager` resolve; they route the request to the person who should decide it, which is a
114115
different and independently useful thing. `os lint` reports the shape at authoring time
115116
(`approval-approvers-may-resolve-empty`, `info`) and carries the same remedy text, so the two
116117
cannot drift apart — and it keeps reporting it even with a fallback declared, because a static
@@ -289,16 +290,16 @@ empty slate you can see than a task the tenancy wall hides.
289290

290291
A result may legitimately be **empty** (a present-but-empty field or variable);
291292
the node-level `onEmptyApprovers` policy decides what that means —
292-
`admin_rescue` (default: the request opens, a privileged admin takes over via
293-
Reassign), `fail` (the node fails: an empty slate is a config bug),
293+
`admin_rescue` (default: the request opens on the organization's administrators —
294+
[When a slate names no one](#when-a-slate-names-no-one)), `fail` (the node fails: an empty slate is a config bug),
294295
`auto_approve` (skip the request and continue down `approve` with
295296
`output.autoApproved = true`; opt-in, since it waves the record through), or
296297
`fallback` (open the request on the people named in `fallbackApprovers`). A
297298
**missing** key (`vars.never_written`) is a loud error instead — guard
298299
genuinely-optional inputs with `has(vars.x) ? vars.x : []`.
299300

300-
`fallback` is the only one of the four that **names people**, so it is the only
301-
one that turns an empty slate into a request someone can actually decide:
301+
`fallback` is how a node **names who decides** an empty slate, instead of leaving it to
302+
the organization's administrators:
302303

303304
```ts
304305
{
@@ -320,8 +321,9 @@ before the node opens any approval request.
320321

321322
Prefer a fallback target that cannot itself resolve empty (a literal `user`, or
322323
the `owner` membership tier). If it does resolve empty the node degrades to
323-
`admin_rescue` — the request still opens and the run is never killed — and the
324-
log says both that the fallback fired and that it found nobody.
324+
`admin_rescue` — the request opens on the organization's administrators and the
325+
run is never killed — and the log says both that the fallback fired and that it
326+
found nobody.
325327

326328
The "previous approver picks the next step's approvers" loop needs no record
327329
field at all: declare `decisionOutputs: ['next_reviewers']` on node A, have the
@@ -349,8 +351,9 @@ where nothing reads the outputs. Enforcement has no elevation bypass — a
349351
one-click email action link and an `auto_approve` SLA escalation both fail
350352
rather than advance into a node that would resolve nobody, leaving the request
351353
pending and visibly overdue. Without the flag your only backstop is
352-
`onEmptyApprovers` — the next node opens, resolves nobody, and stalls for an
353-
admin rescue long after the approver who could have filled it in has gone.
354+
`onEmptyApprovers` — the next node opens, resolves nobody, and lands on the
355+
organization's administrators long after the approver who could have filled it in
356+
has gone.
354357

355358
## The full lifecycle — submit to field change
356359

@@ -409,8 +412,47 @@ What an entry that resolves to nobody leaves on the slate depends on its kind:
409412
- **A slate left empty, or holding only literals,** goes to the node's
410413
`onEmptyApprovers` policy, whose four values are listed under
411414
[Approving across organizations](#cross-org-approvers). Under the default,
412-
`admin_rescue`, the request opens on that slate and waits for an
413-
administrator, or for a holder of a `position:` literal on it.
415+
`admin_rescue`, the request opens on the organization's administrators
416+
instead.
417+
418+
#### When a slate names no one [#when-a-slate-names-no-one]
419+
420+
Under the default `onEmptyApprovers: 'admin_rescue'`, a slate that names no
421+
person opens the request on the **organization's administrators**: the people
422+
who hold the organization-admin capability (`organization_admin` or
423+
`organization_admin_no_bypass`, ADR-0095 D3) in the request's organization. An
424+
organization's owners and admins receive that capability automatically. It is
425+
the same authority that lets a tenant admin override a request, resolved to the
426+
people who hold it.
427+
428+
They are ordinary approvers:
429+
430+
- the request is in each administrator's queue and counted in their bell, under
431+
their own `approverId`, as for any approver;
432+
- each is told once, by `approval.requested`, and out-of-office delegation
433+
applies to them as to any named approver;
434+
- a decision by one of them is an ordinary approval or rejection, recorded with
435+
`via_override: false`. The node's `behavior` reads them as one group: under
436+
`first_response`, or `per_group` with the default `minApprovals`, one of them
437+
decides; `unanimous` and `quorum` count them as they count any slate.
438+
439+
A platform administrator is not added for being one: that standing crosses the
440+
tenant wall, and the slate is the organization's own. One who also holds the
441+
organization's capability, such as its owner, is on the slate for that.
442+
443+
The request says why. Its timeline carries a note, for example *Routed to this
444+
organization's administrators because the approver slate named no one: position
445+
'sales_manager' has no holder.*, and its node-config snapshot keeps the abandoned
446+
slate and the same reason under `__resolvedFrom` (`admin_rescue:abandoned`,
447+
`admin_rescue:reason`). The abandoned slate is replaced, not kept beside them: a
448+
holder staffed into the position after the request opened does not receive it,
449+
and an administrator can reassign it to them.
450+
451+
To name the person who should decide instead, declare
452+
`onEmptyApprovers: 'fallback'` with `fallbackApprovers`. Only an organization
453+
with no administrator, or a request with no organization, still opens on the
454+
slate that names no one: the server logs a warning, no one is told, and only the
455+
admin override decides it.
414456

415457
### The approver finds it in their queue
416458

@@ -453,28 +495,7 @@ position under as their own identity. The deprecated pre-rename prefix is no
453495
alias of it (ADR-0090 D3): it matches only a slot stored under that exact
454496
spelling, and no holder acts under it.
455497
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
456-
`offset`, and `unstaffed`.
457-
458-
`unstaffed=true` adds the pending requests whose slate names no person: only
459-
unstaffed literals such as `position:<name>`, or nothing at all. This is what
460-
`admin_rescue` opens. They
461-
are added only for a caller who may override them: an admin with override
462-
authority (`admin_full_access`, or `organization_admin` within their
463-
organization). Every such row reads `viewer.can_override: true`. For anyone else the parameter
464-
adds nothing. It is ORed with `approverId`, never ANDed, so one call answers
465-
"my pending approvals, rescues included", and its `total` counts both:
466-
467-
```bash
468-
curl -b cookies.txt \
469-
"https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123&unstaffed=true"
470-
```
471-
472-
It reads `true` or `false` only (`false` is the same as leaving it out); any
473-
other value is refused with `400`. The test reads the slate, not the directory:
474-
a `position:<name>` slot whose position gained a holder after the request opened
475-
still matches (that holder also finds it under `approverId`), and a slate that
476-
still names a person never matches, even if that person has since left.
477-
Reassign such a request from the **All** view.
498+
and `offset`.
478499

479500
<Callout type="warn">
480501
**`approverId` is a filter, not authorization.** What you may see is decided
@@ -499,15 +520,17 @@ question back to this service via `fileAccessDelegate`).
499520
**Opening a step tells its approvers.** When a request opens, each concrete
500521
approver on its slate gets one `approval.requested` notification ("Approval
501522
requested", linked to that request in the Approvals Inbox): a named user, every
502-
holder of a staffed position or member of a team, and the `fallbackApprovers`
503-
when the node's `onEmptyApprovers: 'fallback'` opened on them. It goes through
523+
holder of a staffed position or member of a team, the `fallbackApprovers`
524+
when the node's `onEmptyApprovers: 'fallback'` opened on them, and the
525+
organization's administrators when the default `admin_rescue` opened on them. It goes through
504526
the installed messaging service, the same way as the reminder, reassignment,
505527
escalation, and return notifications, and the same delivery preferences apply.
506528
Without a messaging service, no approval notification is delivered at all.
507529

508530
Two cases tell nobody by design. A slot no one holds (`position:<name>` with
509-
no holders) names no person, so a step that resolves to nobody notifies no
510-
one: its `onEmptyApprovers` policy decides what happens instead. An
531+
no holders) names no person, so it is told nothing; a step whose whole slate
532+
names no one is decided by its `onEmptyApprovers` policy, and only in an
533+
organization with no administrator does the default leave no one to tell. An
511534
out-of-office delegate gets one message, `approval.ooo_substituted`, not a
512535
second one for the opening.
513536

@@ -711,9 +734,12 @@ admin can rescue a stuck request. The service stays the sole authority — the
711734
predicate only trims the UI.
712735

713736
<Callout type="info">
714-
**Admin override — recovering a stuck request.** An approval routed to a
715-
`position` / `team` / `department` with **no holders** resolves to only its
716-
`<type>:<name>` literal in `pending_approvers`. A `position:<name>` slot is
737+
**Admin override — recovering a stuck request.** A slate that names no one
738+
opens on the organization's administrators under the default `admin_rescue`
739+
([When a slate names no one](#when-a-slate-names-no-one)), so the override is
740+
break-glass for what that cannot reach: a request whose named approvers have all
741+
left after it opened, and an organization with no administrator, where the
742+
request opens on its `<type>:<name>` literals. A `position:<name>` slot is
717743
decided by whoever holds that position once someone is staffed into it; until
718744
then — and for a `team` or `department` literal, always — no concrete user can
719745
act, and (with `lockRecord`) the record stays locked. A **platform admin**

0 commit comments

Comments
 (0)