Repository navigation
Commit b98e984
fix(plugin-approvals): a manager approver whose owner has no manager adds no slot, instead of
Fixes #22558
Clause-②: no
## What changes
A person approver that resolves to nobody now adds **no slot** to the
request. The two person types are `manager` (the record subject's
`sys_user.manager_id`) and `field` (the user ids in a record field).
Before this change, `resolveApproverSpec` stored the literal string
`TYPE:VALUE` in that case. For the usual `{ type: 'manager' }`, which
has no `value`, that string was `manager:undefined`. Both read doors
(`GET /api/v1/approvals/requests` and `GET
/api/v1/data/sys_approval_request`) served it as if it were a real
approver.
The request now opens on an empty `pending_approvers`, and the node's
`onEmptyApprovers` policy decides it. This is what
`content/docs/automation/approvals.mdx` states: "An entry that resolves
to nobody is not an error: the request opens with an empty
`pending_approvers`".
- **`approval-service.ts`:**
- A new `PERSON_APPROVER_TYPES` set holds `manager` and `field`. After
an empty lookup, `resolveApproverSpec` returns `[]` for these two types,
before it reaches the literal fallback.
- `manager` leaves `GRAPH_APPROVER_TYPES`. It keeps a warning of its
own: the new warning says the rung adds no slot and names the subject
user, where the old one said "the slot routes to no one".
- The docblocks on `expandApprovers` and `resolveApproverSpec`, and the
comment on the `fallback` branch, are updated.
- **Tests:** the fixture triage below, plus eight new pins in
`approval-service.test.ts` (describe "a person approver that resolves to
nobody adds no slot").
- **Changeset:** `.changeset/22558-empty-approver-no-slot.md`, `patch`
for `@objectstack/plugin-approvals`.
## Premise check (measured on `origin/main` `6a3f82efa7`)
- **What holds:** a `{ type: 'manager' }` step stores
`pending_approvers: ["manager:undefined"]` under the default
`onEmptyApprovers: 'admin_rescue'` when the owner's `sys_user` row has
`manager_id: null`. The ablation below reproduces it: it puts the
literal back and reads `expected [ 'manager:undefined' ] to deeply equal
[]`.
- **What does not hold as written:** "keeps `onEmptyApprovers` from
firing".
- `openNodeRequest` counts a slate as empty when it holds no CONCRETE
person, meaning no entry without a colon (its `approvers.some(...)` test
with `!a.includes(':')`). So a slate of only literals already reached
the policy, on both the primary expansion and the `fallback` expansion.
- `fail`, `auto_approve` and `fallback` were already pinned on exactly
the `manager:undefined` rung (describe "onEmptyApprovers: 'fallback'").
- **What the literal did break:**
1. Under `admin_rescue`, the stored slate held a dead slot instead of
being empty.
2. In a multi-approver slate, the dead slot sat beside the real
approvers. Under `unanimous`, `quorum` or `per_group` nobody could
approve it, so it held the request open after every real approver had
approved.
- **Later nodes:** every approval node opens through the same
`openNodeRequest`. The third `expandApprovers` call site is the
decide-time tally re-expansion for requests that have no open-time
snapshot (see Stored rows).
## Verdict per approver type
| Approver type | Resolves to | Empty lookup before | After | Why |
|---|---|---|---|---|
| `manager` | the record subject's `sys_user.manager_id` (OOO-delegated)
| `manager:VALUE` (`manager:undefined` without `value`) | **no slot**,
plus a warning | Person type. The acting path (`approver-address.ts`)
admits a caller only under their user id, their own account's email, or
`position:NAME` of a position they hold. Nobody can ever hold
`manager:VALUE`. |
| `field` | the user ids in `record[value]` | `[]` when there is a
record; `field:NAME` only when there is none | **no slot** everywhere |
Person type, same reason. On the open path `loadLiveRecord` always
returns an object, so only the legacy tally re-expansion could reach the
literal. |
| `user` | `value` itself | never empty | unchanged | Its literal IS the
slot. |
| `expression`, `resolveAs: 'user'` | the ids the CEL expression yields
| `[]` | unchanged | Already contributes nothing. |
| `position` (and `expression` with `resolveAs: 'position'`) | holders |
`position:NAME` | **kept** | The literal IS an acting address.
`positionAddresses`, `actingAddresses` and `heldSlot` let a holder who
is staffed in later take it; the docs say it is "decided by whoever
holds that position once someone is staffed into it". |
| `team`, `department` (`business_unit`, `bu`), `org_membership_level`
(deprecated `role`), `expression` with `resolveAs` `team` or
`department` | members | `TYPE:VALUE` | **kept, unchanged** | Not an
acting address. See the open question in Acceptance notes. |
| `queue` | nothing (not implemented, not authorable) | `queue:VALUE`,
plus a warning | unchanged | Out of scope. |
## Stored rows
**Measured in this repo: zero rows.**
- No seeded or fixture `sys_approval_request` row carries a `manager:`
or `field:` slot: `git grep -nE "pending_approvers.*(manager|field):" --
examples packages/qa` outside tests returns 0.
- Control: the same tree holds 1 non-test `pending_approvers` mention,
and the showcase demo seed
(`examples/app-showcase/src/security/seed-approval-demo.ts`) routes to
positions.
- A deployment's rows cannot be measured from here. The reporting app
(17.7.0) holds at least one.
**Disposition: no rewrite.** A pending request that already holds
`manager:undefined` (or `field:NAME`) keeps that slot. Two cases:
- **Request with an open-time tally snapshot** (`__approverGroups`;
every non-`first_response` request carries one):
- The slot stays, and real approvers decide as before.
- Under `unanimous`, `quorum` or `per_group` the dead slot still holds
the request open. A platform or tenant admin recovers it exactly as
before, by approve, reject, reassign or recall.
- Pinned: "stored row WITH the open-time slate snapshot keeps its dead
slot: not rewritten, still an admin rescue".
- **Request opened before that snapshot existed:**
- Its tally is re-resolved at the next approve. The dead person slots no
longer count, so the real approvers' approvals finish the step.
- Pinned: "stored row WITHOUT a snapshot is re-evaluated at its next
approve".
**Listing them for an operator:** query `sys_approval_request` for
`status = pending` and `pending_approvers` containing `manager:` or
`field:`. The changeset gives the same query.
## Ablation (from committed HEAD `49a690c1`)
Each leg ran through `scripts/ablation-replace.mjs` in wrap mode, over
the two touched test files. The tests import `./approval-service.js`
relatively, and vitest resolves that to `src`. No `dist` is in the path,
so the ablation needed no rebuild leg.
- **Leg 1, the literal fallback put back for both person types:**
- Mutation: ` if (PERSON_APPROVER_TYPES.has(type)) {` became ` if (false
&& PERSON_APPROVER_TYPES.has(type)) {`. Anchor count went 1 to 0; blob
`f95d8067dbae` became `4a2a9ef66717`.
- Result: `Tests 9 failed | 350 passed (359)`.
- Red: the `#3807` manager case; the three `admin_rescue` degradations
in the `fallback` describe; "a manager step for an owner with no
manager_id opens on an EMPTY pending_approvers"; "beside a concrete
approver ... does not hold a unanimous request open"; "stored row
WITHOUT a snapshot"; and org-screen A and C-a2.
- **Leg 2, `field` removed from the set only:**
- Mutation: `new Set(['manager', 'field'])` became `new
Set(['manager'])`. Blob became `f1cf085ceb06`.
- Result: `Tests 1 failed | 358 passed`. The red test is "stored row
WITHOUT a snapshot": `expected false to be true` on `vote.finalized`.
- **Restore:** proven after each leg. The blob equals HEAD's
`f95d8067dbae`, and `git diff HEAD` is empty.
- **Controls:** green under both legs. An owner with a manager gets
`u_boss`; an unstaffed position keeps `position:cfo`; `fallback` opens
on the named people.
## Tests and gates (at `49a690c1`)
- `pnpm --filter @objectstack/plugin-approvals exec vitest run
--maxWorkers=2`: 64 files, 924 tests passed. `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0
(`check:test-typecheck: OK`). The ledger is unchanged at 324.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 67 commands:
65 exited 0, and 2 exited 3 (`PREREQUISITE NOT MET`), which means NOT
MEASURED:
- `pnpm check:dual-build-cjs-loads` needs every package's `dist`, which
takes a whole-repo build. CI runs it.
- `pnpm check:i18n` needs the CLI build closure. This plugin's extract
config imports only the three `sys-approval-*.object.ts` files and the
translation bundles, and this diff touches none of them. CI runs it.
- `--ran` reconciliation: "67 derived famil(ies) accounted for — 65 run,
2 NOT-MEASURED".
- `main` has moved 6 commits since the base. None of them touches
`packages/plugins/plugin-approvals` or
`content/docs/automation/approvals.mdx`. I did not merge `main`; CI
tests the merge ref.
## Fixture triage
- **`approval-service.test.ts`, the `#3807` table's `manager` row —
replaced.** The row pinned `manager:undefined` and the "slot routes to
no one" warning, which is exactly the deleted branch. A `manager` case
now asserts an empty slate, no "expanded to nobody" warning, and the new
warning's meta.
- **`approval-service.test.ts`, describe "onEmptyApprovers: 'fallback'"
— re-spelled.**
- Three `toEqual(['manager:undefined'])` assertions became
`toEqual([])`. The subject is unchanged: the request still opens under
`admin_rescue`.
- The fallback-fired needle `onEmptyApprovers: 'fallback'` became `per
onEmptyApprovers: 'fallback'`. The manager warning now names that policy
as its remedy, so the old needle would have made the positive "fallback
fired" pin vacuous.
- **`manager-approver-org-screen.test.ts`, A and C-a2 — re-spelled.**
`['manager:owner_id']` became `[]`. A also asserts
`not.toContain('u_mgr_b')`, which replaces a no-concrete-approver check
that `[]` makes vacuous.
## Acceptance notes
- **Multi-approver slates behave differently.** This follows from the
binding direction ("adds no slot").
- With no manager, `[{ type: 'manager' }, { type: 'user', value: 'u9'
}]` under `unanimous` now finalizes on u9's approval (pinned). This
matches the docs' advice, for an unset manager, to "add a second
approver entry that cannot resolve empty".
- Pre-existing: `lookupManager` treats a failed directory read as "no
manager". That case now also adds no slot, and the warning lists it as a
possible cause.
- **Open question, not changed here: the group types.**
- The triage keeps the literal "only for types whose literal IS the
slot". Read as covering every type, only `position` qualifies, so
`team`, `department` and `org_membership_level` would stop leaving a
literal too.
- Not done here, for three reasons:
1. The docs' admin-override callout says such a request "resolves to
only its `TYPE:NAME` literal in `pending_approvers`" for `team` and
`department`, and this claim excludes `content/docs`.
2. The released 17.2.0, 17.4.0 and 17.5.0 changelogs describe that
literal.
3. For a group, the literal fails closed under `unanimous` and
`per_group`: an empty required group (Finance AND Legal) holds the
request for an admin instead of approving without that group.
- The docs' general sentence and the admin-override callout disagree
about group types today. This is raised with the PM for a decision.
- **Observation, not filed:** the spec's `value` is optional for every
approver type. `{ type: 'user' }` with no `value` would store the
literal `undefined` as a CONCRETE slot (it has no colon), which also
defeats the empty-slate test. No measured producer reaches it.
- **Readers of the slate:** the decide, act and visibility readers
already treat an entry containing a colon as a literal (notify, the
remind fan-out, the display-name lookup). An empty slate needs nothing
from them. An admin reassign on an empty slate already hands the request
to `to` (`next = [to]`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_
---------
Co-authored-by: Claude <noreply@anthropic.com>manager:undefined (#22583)1 parent 6a06e3b commit b98e984
4 files changed
Lines changed: 326 additions & 36 deletions
File tree
- .changeset
- packages/plugins/plugin-approvals/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3349 | 3349 | | |
3350 | 3350 | | |
3351 | 3351 | | |
| 3352 | + | |
| 3353 | + | |
| 3354 | + | |
| 3355 | + | |
3352 | 3356 | | |
3353 | 3357 | | |
3354 | 3358 | | |
3355 | 3359 | | |
3356 | 3360 | | |
3357 | | - | |
3358 | | - | |
3359 | | - | |
3360 | | - | |
3361 | | - | |
3362 | | - | |
3363 | 3361 | | |
3364 | 3362 | | |
3365 | 3363 | | |
| |||
3372 | 3370 | | |
3373 | 3371 | | |
3374 | 3372 | | |
| 3373 | + | |
| 3374 | + | |
| 3375 | + | |
| 3376 | + | |
| 3377 | + | |
| 3378 | + | |
| 3379 | + | |
| 3380 | + | |
| 3381 | + | |
| 3382 | + | |
| 3383 | + | |
| 3384 | + | |
| 3385 | + | |
| 3386 | + | |
| 3387 | + | |
3375 | 3388 | | |
3376 | 3389 | | |
3377 | 3390 | | |
| |||
3394 | 3407 | | |
3395 | 3408 | | |
3396 | 3409 | | |
3397 | | - | |
3398 | | - | |
| 3410 | + | |
| 3411 | + | |
3399 | 3412 | | |
3400 | 3413 | | |
3401 | | - | |
3402 | | - | |
3403 | | - | |
| 3414 | + | |
| 3415 | + | |
| 3416 | + | |
| 3417 | + | |
| 3418 | + | |
3404 | 3419 | | |
3405 | 3420 | | |
3406 | 3421 | | |
| |||
3427 | 3442 | | |
3428 | 3443 | | |
3429 | 3444 | | |
| 3445 | + | |
| 3446 | + | |
| 3447 | + | |
| 3448 | + | |
| 3449 | + | |
3430 | 3450 | | |
3431 | 3451 | | |
3432 | 3452 | | |
| |||
3439 | 3459 | | |
3440 | 3460 | | |
3441 | 3461 | | |
3442 | | - | |
| 3462 | + | |
3443 | 3463 | | |
3444 | 3464 | | |
3445 | 3465 | | |
| |||
3493 | 3513 | | |
3494 | 3514 | | |
3495 | 3515 | | |
3496 | | - | |
| 3516 | + | |
| 3517 | + | |
3497 | 3518 | | |
3498 | 3519 | | |
3499 | 3520 | | |
| |||
3506 | 3527 | | |
3507 | 3528 | | |
3508 | 3529 | | |
3509 | | - | |
| 3530 | + | |
3510 | 3531 | | |
3511 | 3532 | | |
3512 | 3533 | | |
| |||
3526 | 3547 | | |
3527 | 3548 | | |
3528 | 3549 | | |
3529 | | - | |
| 3550 | + | |
3530 | 3551 | | |
3531 | 3552 | | |
3532 | 3553 | | |
| |||
3554 | 3575 | | |
3555 | 3576 | | |
3556 | 3577 | | |
3557 | | - | |
| 3578 | + | |
3558 | 3579 | | |
3559 | 3580 | | |
3560 | 3581 | | |
3561 | 3582 | | |
3562 | 3583 | | |
3563 | | - | |
| 3584 | + | |
| 3585 | + | |
| 3586 | + | |
| 3587 | + | |
| 3588 | + | |
| 3589 | + | |
| 3590 | + | |
| 3591 | + | |
| 3592 | + | |
| 3593 | + | |
| 3594 | + | |
| 3595 | + | |
| 3596 | + | |
| 3597 | + | |
| 3598 | + | |
| 3599 | + | |
| 3600 | + | |
| 3601 | + | |
| 3602 | + | |
| 3603 | + | |
| 3604 | + | |
| 3605 | + | |
| 3606 | + | |
| 3607 | + | |
| 3608 | + | |
| 3609 | + | |
| 3610 | + | |
| 3611 | + | |
| 3612 | + | |
| 3613 | + | |
| 3614 | + | |
| 3615 | + | |
| 3616 | + | |
| 3617 | + | |
| 3618 | + | |
| 3619 | + | |
| 3620 | + | |
| 3621 | + | |
| 3622 | + | |
| 3623 | + | |
| 3624 | + | |
| 3625 | + | |
| 3626 | + | |
| 3627 | + | |
| 3628 | + | |
| 3629 | + | |
| 3630 | + | |
| 3631 | + | |
| 3632 | + | |
| 3633 | + | |
| 3634 | + | |
| 3635 | + | |
| 3636 | + | |
| 3637 | + | |
| 3638 | + | |
| 3639 | + | |
| 3640 | + | |
| 3641 | + | |
| 3642 | + | |
| 3643 | + | |
| 3644 | + | |
| 3645 | + | |
| 3646 | + | |
| 3647 | + | |
| 3648 | + | |
| 3649 | + | |
3564 | 3650 | | |
3565 | | - | |
| 3651 | + | |
| 3652 | + | |
| 3653 | + | |
| 3654 | + | |
| 3655 | + | |
| 3656 | + | |
| 3657 | + | |
| 3658 | + | |
| 3659 | + | |
| 3660 | + | |
| 3661 | + | |
| 3662 | + | |
| 3663 | + | |
| 3664 | + | |
| 3665 | + | |
| 3666 | + | |
| 3667 | + | |
| 3668 | + | |
| 3669 | + | |
| 3670 | + | |
| 3671 | + | |
| 3672 | + | |
| 3673 | + | |
| 3674 | + | |
| 3675 | + | |
| 3676 | + | |
| 3677 | + | |
| 3678 | + | |
| 3679 | + | |
| 3680 | + | |
| 3681 | + | |
| 3682 | + | |
| 3683 | + | |
| 3684 | + | |
| 3685 | + | |
| 3686 | + | |
| 3687 | + | |
| 3688 | + | |
| 3689 | + | |
| 3690 | + | |
| 3691 | + | |
| 3692 | + | |
| 3693 | + | |
| 3694 | + | |
| 3695 | + | |
| 3696 | + | |
| 3697 | + | |
| 3698 | + | |
| 3699 | + | |
| 3700 | + | |
| 3701 | + | |
| 3702 | + | |
| 3703 | + | |
| 3704 | + | |
| 3705 | + | |
| 3706 | + | |
| 3707 | + | |
| 3708 | + | |
| 3709 | + | |
| 3710 | + | |
| 3711 | + | |
| 3712 | + | |
| 3713 | + | |
| 3714 | + | |
| 3715 | + | |
| 3716 | + | |
| 3717 | + | |
| 3718 | + | |
| 3719 | + | |
| 3720 | + | |
| 3721 | + | |
| 3722 | + | |
| 3723 | + | |
| 3724 | + | |
| 3725 | + | |
| 3726 | + | |
| 3727 | + | |
| 3728 | + | |
| 3729 | + | |
| 3730 | + | |
| 3731 | + | |
| 3732 | + | |
| 3733 | + | |
| 3734 | + | |
| 3735 | + | |
| 3736 | + | |
| 3737 | + | |
| 3738 | + | |
| 3739 | + | |
| 3740 | + | |
| 3741 | + | |
| 3742 | + | |
| 3743 | + | |
| 3744 | + | |
| 3745 | + | |
| 3746 | + | |
| 3747 | + | |
| 3748 | + | |
| 3749 | + | |
| 3750 | + | |
| 3751 | + | |
| 3752 | + | |
| 3753 | + | |
| 3754 | + | |
| 3755 | + | |
| 3756 | + | |
| 3757 | + | |
| 3758 | + | |
| 3759 | + | |
| 3760 | + | |
| 3761 | + | |
| 3762 | + | |
| 3763 | + | |
| 3764 | + | |
| 3765 | + | |
| 3766 | + | |
| 3767 | + | |
| 3768 | + | |
| 3769 | + | |
3566 | 3770 | | |
3567 | 3771 | | |
3568 | 3772 | | |
| |||
0 commit comments