Repository navigation
feat(spec,plugin-approvals,rest): an unstaffed arm on the approvals inbox read returns an admin_rescue request to a caller who may override it - #22775
Conversation
…nbox read returns an admin_rescue request to a caller who may override it (#22725) IApprovalService.listRequests' filter declares `unstaffed?: boolean`, ORed with `approverId`: pending requests whose current slate names no person, returned only where isOverrideActor admits the caller. listRequests and countRequests read it through one helper; the REST list door declares `?unstaffed=true|false` and refuses any other value with 400 invalid_boolean. The open path and the arm share one shape test, slateNamesAPerson. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…ps an org-less tenant admin to org-less rescues Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…taffed-slate scan Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…oor on a real boot Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…min-rescue-queue-arm
…ory — a later-staffed position slot still matches Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…min-rescue-queue-arm
…min-rescue-queue-arm
📓 Docs Drift CheckThis PR changes 3 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c2ccf0bb6eee48bdc4961802e9951ec6d2283eb1 && git checkout c2ccf0bb6eee48bdc4961802e9951ec6d2283eb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e84aeb36ce14169a633670f14ce8280fc998e2b9 640aed576dcfc7cd43e18b43c424296299124c51 && git checkout -B drift-repro e84aeb36ce14169a633670f14ce8280fc998e2b9 && git merge --no-ff 640aed576dcfc7cd43e18b43c424296299124c51
node scripts/docs-audit/affected-docs.mjs --json e84aeb36ce14169a633670f14ce8280fc998e2b9
|
…min-rescue-queue-arm
…w the head serves — the who-acts-next arms resolve to a bounded id set first Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review over the held ① Derived judgmentsCheck-runs on the head: 42, every one concluded — 38 success, 4 skipped, 0 failed, 0 cancelled. The skips: Console Pin Gate (path-scheduled; this diff removes nothing), Packed-tarball smoke (opt-in), and one Auto Label plus one Check PR Size row from a second run of the PR-meta suite on the same head, each green in the other run; none of the four is a required context. All seven required contexts on Carried forward from
Re-judged, the delta:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…nization'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>
Fixes #22725
Clause-②: yes (widening)
An
onEmptyApprovers: 'admin_rescue'request opens on a slate that names no person (the literalposition:NAME, or nothing). A privileged admin may decide it, but nothing put it in that admin's queue.approverIdmatches a slot against the addresses the caller names, and an admin's own addresses never equal an unstaffedposition:NAME. So no "My Pending" listed it, the bell read 0, and alockRecord: truerecord stayed locked until someone looked under All. This PR adds the one server-side arm that returns such a request to a caller who may override it. It is one vertical PR, as triage's answer A on the card directs.What changed
packages/spec/src/contracts/approval-service.ts):IApprovalService.listRequests' filter gains ONE optional member,unstaffed?: boolean, with its docblock.countRequeststakes the same filter type aslistRequests(its first parameter), so it answers the member too. Nothing else on the filter moves:approverIdmatches exactly what it matched before.packages/plugins/plugin-approvals):awaitingRequestIdsis the one helperlistRequestsandcountRequestsboth call. It returnsapproverRequestIds(approverId)ORed withunstaffedOverridableRequestIdswhenunstaffed === true, so the list and its pagedtotalcannot disagree.unstaffedOverridableRequestIdsscans the pending requests inside the caller's organization wall (SYSTEM_CTX, the approver-index window). It keeps a row when the slate names no person ANDisOverrideActor(context, row.organization_id)holds.isOverrideActoris the only "admin" read, and there is no second notion. It is asked first asvisibleRequestIdsasks it, so a caller who may override nothing pays no scan. Then it is asked per row, asattachViewersasks it forviewer.can_override, so every added row readscan_override: true.slateNamesAPerson(inapprover-address.ts) is the shape test, now shared. The open path's three copies of the inline test that decides "decidable only by a privileged admin" read it too, so the slate the open path warns about and the slate the arm returns cannot drift apart. These are mechanical replacements and change no behaviour.packages/rest/src/rest-server.ts):unstaffedjoinsAPPROVAL_REQUEST_LIST_PARAMSand the single-valued multiplicity set.readDeclaredQueryBooleanreads it astrueorfalseonly. Anything else (1,TRUE,yes, an empty?unstaffed=) is refused400 VALIDATION_FAILEDwithfields[0]{ field: 'unstaffed', code: 'invalid_boolean' }through the door'shandleRouteError. That is the envelope itslimit/offsetrefusals answer, and the accepted set and code are runtime'sparseBooleanParam(?read=on the notifications inbox). A repeated?unstaffed=gets the multiplicity400.@objectstack/spec,@objectstack/plugin-approvalsand@objectstack/rest, allminor.The member's shape: the spec seat's contract call, on the four axes
A NEW explicit member, ORed with
approverId, never ANDed. I judge this a contract-shape call, not a product split. The card's done-when and the filed console follow-up (objectui#12102: "My Pending and the bell include the requests the server's rescue arm returns") already place the rescue requests in My Pending and the bell.status=pending&approverId=IDSin objectuiapprovalRequestsDataSource.ts(theawaiting_mescope, paged server-side throughlimit/offset/total) andsharedUserFeeds.ts(useSharedPendingApprovalsCount). Under an AND member, the console would need two requests merged on the client. AListViewover one data source cannot page that, and the bell would sum two counts. Under OR, the follow-up is one added parameter on each existing request. The badge objectui#12102 asks for is already on the wire: a rescue row readsviewer.can_act: false, can_override: true.approverIdis NOT re-meant for override actors. That would silently change a published member for every caller, including callers that never asked. The new member is opt-in and declared, and the REST door declares it in its closed set.falseequals absent rule, sounstaffed: falsecannot be read as "exclude". The REST door refuses every spelling buttrue/false, so a guessed value never answers a200without the arm, a reply shaped exactly like "nothing to rescue". The member's name says what it selects, not which policy produced it.Which population the arm returns, and why
The arm returns pending requests whose current
pending_approversholds no user id and no email: onlytype:valueliterals, or nothing at all. This is the test the open path makes before it warns "decidable only by a privileged admin", read off the stored slate as it stands, never off a flag stamped at open time. So the arm covers more thanadmin_rescue's opening slot:admin_rescueopen on a literal (position:NAME,team:NAME) or on an empty slate (an expression ormanagerrung that resolved to nobody). It also covers afallbackthat degraded toadmin_rescue, a stored pre-rename literal that only the override can decide, and a slate whose last named approver acted and left only literals (pinned:unanimousover[u9, position:sales_manager]joins the arm once u9 approves).position:NAMEslot whose position gained a holder AFTER the request opened. That holder also finds it underapproverId, and the admin can still decide it, so the arm never shows a request its caller cannot act on. Dropping it would need a live directory read per literal, a second staffing notion beside the open path's.reassign.Pins and evidence (measured on this branch)
Service, through
listRequestsANDcountRequests(approval-service.test.ts,ApprovalService — the unstaffed arm (#22725), 9 cases):admin_rescuerequest (literal slot, and empty slate) appears for a tenant admin and a platform admin, with every row readingcan_override: true;approverId, across a paged window;unstaffed: falseequals absent; a tenant admin's arm stops at their organization; per row, an org-less tenant admin reaches org-less rescues only; the slate as it stands; a finalized rescue leaves the arm.REST door, unit (
rest-server-approvals-unknown-filter.test.ts§4):true/falsereach bothlistRequestsandcountRequestsbesideapproverId. Absent hands no arm.1/TRUE/yes/ empty are refused400 invalid_boolean, and neither service method runs. Repeated is refused by the multiplicity gate. The closed-set preservation loop sendsunstaffedone of its own values.Through the REST door on a real boot (
packages/qa/dogfood/test/approval-unstaffed-arm.dogfood.test.ts, a new two-object fixture): the card's three pins on the paged list and itstotal(the bell's number), plus the door's400.Ablations, each run from a committed state with the restore proven by blob equal to HEAD and an empty
git diff HEAD:if (filter?.unstaffed !== true || true) return byApprover;): 6 of the 8 service cases red. The 2 that stay green (falseequals absent, the tenant wall) do not depend on the arm being on.q.unstaffed === 'true': 5 door cases red (the four refusals, and "absent hands no arm").dist/: the arm mutation rebuilt intoplugin-approvals.ablation-dist-preflightconfirmed the marker indist/index.jsanddist/index.mjs, and the pin went red on the admin'sapproverId-plus-unstaffedread. After the restore, the rebuiltdist/had the marker absent and the tree clean.Suites (full, not filtered), measured at the PR head
bb112188a9(after mergingorigin/mainat996aa86e0a):pnpm --filter @objectstack/plugin-approvals test: 71 files, 1032 tests passed;pnpm --filter @objectstack/rest test: 272 files, 5247 passed, 326 skipped;approval-unstaffed-arm, plus the two neighbouring approvals pins (approval-override-composite-pin,my-pending-position-address): 3 of 3 passed;typecheckexit 0 forplugin-approvalsandrest(both runcheck:test-typecheck, which covers their test files), forspec, and fordogfood, whosetscprogram lists both new files;pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date.api-surfacerecords the interface by name, so the member moves no snapshot.Gates at
bb112188a9:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranreports 114 derived families, 114 run, 0 NOT-MEASURED, 0 UNRUN. Every one carries a recorded exit code, and all are 0.check-changeset-fixed,check:meta-url-spelling,check:error-code-casing,check:filter-alias-parity,check:engine-double-contract,check:route-ledger-census.**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}(eslint.config.mjs), so the two Markdown files are outside it. All 8 changed TypeScript files were linted with--no-inline-config --format json: 8 files, 0 errors, 0 warnings. The config never enables type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.Acceptance notes
@objectstack/client'sapprovals.listRequestshand-writes its filter type, and it was already a subset of the contract's (noq,limitoroffset). The console reads the door through its ownapprovalsApi, so there is no measured consumer of the SDK method for this member. It is noted here, not filed. Carrier: none.@objectstack/restcannot import@objectstack/runtime'sparseBooleanParam(runtime depends on rest). The reader's docblock names the rule it mirrors, the accepted set, and the code. Carrier: none.listRequests'limitdocblock in the contract: corrected in this PR (patch round 1,640aed576d). It said anapproverId/ status-array filter "post-filters in memory", with the window applied after filtering, which was false at the head. It now says what the head serves: the window is engine-level for every filter (a status array pushes down as$in), and the two "who acts next" arms (approverId,unstaffed) first resolve to a bounded id set (the approver index and the pending scan, each withinAPPROVER_INDEX_CAP), with the window applied to that set engine-side.content/docs/automation/approvals.mdx) and the dogfood pin plus its fixture, which carry the claim's "each pin runs through the service and through the REST door".Generated by Claude Code