Repository navigation
Commit 69d4218
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
File tree
- .changeset
- content/docs
- automation
- permissions
- references/automation
- docs/audits
- packages
- lint/src
- plugins/plugin-approvals/src
- qa/dogfood/test
- fixtures
- rest/src
- spec/src
- automation
- contracts
This file was deleted.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | | - | |
108 | | - | |
109 | | - | |
110 | | - | |
111 | | - | |
112 | | - | |
113 | | - | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
114 | 115 | | |
115 | 116 | | |
116 | 117 | | |
| |||
289 | 290 | | |
290 | 291 | | |
291 | 292 | | |
292 | | - | |
293 | | - | |
| 293 | + | |
| 294 | + | |
294 | 295 | | |
295 | 296 | | |
296 | 297 | | |
297 | 298 | | |
298 | 299 | | |
299 | 300 | | |
300 | | - | |
301 | | - | |
| 301 | + | |
| 302 | + | |
302 | 303 | | |
303 | 304 | | |
304 | 305 | | |
| |||
320 | 321 | | |
321 | 322 | | |
322 | 323 | | |
323 | | - | |
324 | | - | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
325 | 327 | | |
326 | 328 | | |
327 | 329 | | |
| |||
349 | 351 | | |
350 | 352 | | |
351 | 353 | | |
352 | | - | |
353 | | - | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
354 | 357 | | |
355 | 358 | | |
356 | 359 | | |
| |||
409 | 412 | | |
410 | 413 | | |
411 | 414 | | |
412 | | - | |
413 | | - | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
414 | 456 | | |
415 | 457 | | |
416 | 458 | | |
| |||
453 | 495 | | |
454 | 496 | | |
455 | 497 | | |
456 | | - | |
457 | | - | |
458 | | - | |
459 | | - | |
460 | | - | |
461 | | - | |
462 | | - | |
463 | | - | |
464 | | - | |
465 | | - | |
466 | | - | |
467 | | - | |
468 | | - | |
469 | | - | |
470 | | - | |
471 | | - | |
472 | | - | |
473 | | - | |
474 | | - | |
475 | | - | |
476 | | - | |
477 | | - | |
| 498 | + | |
478 | 499 | | |
479 | 500 | | |
480 | 501 | | |
| |||
499 | 520 | | |
500 | 521 | | |
501 | 522 | | |
502 | | - | |
503 | | - | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
504 | 526 | | |
505 | 527 | | |
506 | 528 | | |
507 | 529 | | |
508 | 530 | | |
509 | | - | |
510 | | - | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
511 | 534 | | |
512 | 535 | | |
513 | 536 | | |
| |||
711 | 734 | | |
712 | 735 | | |
713 | 736 | | |
714 | | - | |
715 | | - | |
716 | | - | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
717 | 743 | | |
718 | 744 | | |
719 | 745 | | |
| |||
0 commit comments