Skip to content

Commit b98e984

Browse files
fix(plugin-approvals): a manager approver whose owner has no manager adds no slot, instead of manager:undefined (#22583)
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>
1 parent 6a06e3b commit b98e984

4 files changed

Lines changed: 326 additions & 36 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): a `manager` approver whose record owner has no manager adds no slot to the request, instead of the literal `manager:undefined`
6+
7+
Clause-②: no
8+
9+
An approval request whose user-resolving approver resolves to nobody now opens on an empty `pending_approvers`, and the node's `onEmptyApprovers` policy applies, as `content/docs/automation/approvals.mdx` documents ("An entry that resolves to nobody is not an error: the request opens with an empty `pending_approvers`"). This covers `{ type: 'manager' }` when the record's owner has no `sys_user.manager_id`, or the manager is outside the request's organization, and `{ type: 'field' }` when there is no record to read.
10+
11+
Before this fix the expansion stored a literal `manager:<value>` slot: `manager:undefined` when the approver authors no `value`, which is the usual case. Both read doors (`GET /api/v1/approvals/requests` and `GET /api/v1/data/sys_approval_request`) served that slot. Nobody can ever act under it: a caller acts only under their user id, their own account's email, or the `position:<name>` address of a position they hold.
12+
13+
What changes, and for whom:
14+
15+
- **A slate made only of such rungs** opens with `pending_approvers: []` under the default `onEmptyApprovers: 'admin_rescue'`, where it held `["manager:undefined"]` before. `fail`, `auto_approve` and `fallback` behave as before, because those policies already treated a slate of literals as empty.
16+
- **A slate with other approvers** (for example `[{ type: 'manager' }, { type: 'user', value: 'u9' }]`) is carried by the others. Under `unanimous`, `quorum` or `per_group`, the dead literal used to be an unapprovable slot: it held the request open after every real approver had approved, until an admin overrode it. Now the real approvers' approvals finish the step. This is the "second approver entry that cannot resolve empty" escape that the docs name for an unset manager.
17+
- **Group approvers are unchanged.** An unstaffed `position` still opens on its `position:<name>` literal, which a holder staffed into the position later decides. `team`, `department` and `org_membership_level` still fall back to their `type:value` literal.
18+
- The `manager` rung still logs a warning when it resolves to nobody. The warning now says that the rung adds no slot, and names the subject user.
19+
20+
Stored requests are not rewritten. A pending request that already holds a `manager:…` or `field:…` slot keeps it, and it stays recoverable as before: a platform or tenant admin can approve, reject, reassign or recall it. To list such requests, filter `sys_approval_request` on `status = pending` and on `pending_approvers` containing `manager:` or `field:`.

‎packages/plugins/plugin-approvals/src/approval-service.test.ts‎

Lines changed: 222 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3349,17 +3349,15 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38
33493349
},
33503350
});
33513351

3352+
// `manager` is no longer a row here (#22558): it names a PERSON, so resolving
3353+
// to nobody now adds no slot at all instead of the dead `manager:undefined`
3354+
// literal this table used to pin — see the `manager` case just below and the
3355+
// #22558 describe further down.
33523356
it.each([
33533357
['team', 'team_gone'],
33543358
['department', 'bu_gone'],
33553359
['position', 'nobody_holds_this'],
33563360
['org_membership_level', 'member'],
3357-
// `manager` is in GRAPH_APPROVER_TYPES like the four above, and the row was
3358-
// missing from this table. It is the worst of the set, not the mildest: it
3359-
// authors no `value`, so the dead literal it leaves is `manager:undefined`
3360-
// — a slot that names a person who does not exist AND reads like a bug in
3361-
// the platform to whoever finds it in `pending_approvers`.
3362-
['manager', undefined],
33633361
])('%s: the dead literal is logged with its type, value and org', async (type, value) => {
33643362
const engine = makeFakeEngine();
33653363
const { svc, warnings } = svcWithWarnings(engine);
@@ -3372,6 +3370,21 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38
33723370
expect(hit[1]).toMatchObject({ type, value, organizationId: 't1' });
33733371
});
33743372

3373+
// The warning `manager` had as a graph type survives without a slot: an
3374+
// unset `sys_user.manager_id` is the common cause and nothing else on the
3375+
// request names it. What it must no longer say is that a slot routes anywhere.
3376+
it('manager: resolving to nobody adds NO slot, and the warning says so', async () => {
3377+
const engine = makeFakeEngine();
3378+
const { svc, warnings } = svcWithWarnings(engine);
3379+
const req = await svc.openNodeRequest(approverInput('manager') as any, CTX) as any;
3380+
3381+
expect(req.pending_approvers).toEqual([]);
3382+
expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
3383+
const hit = warnings.find(([msg]) => String(msg).includes("approver 'manager' resolved to nobody"));
3384+
expect(hit, 'no warning for manager').toBeTruthy();
3385+
expect(hit[1]).toMatchObject({ type: 'manager', value: null, organizationId: 't1' });
3386+
});
3387+
33753388
it('stays quiet when the graph DOES resolve someone', async () => {
33763389
const engine = makeFakeEngine();
33773390
engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_ok', user_id: 'u5' }];
@@ -3394,13 +3407,15 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38
33943407

33953408
// The empty-slate policy that NAMES people. Before it, an empty
33963409
// `{ type: 'manager' }` rung had three endings and all three were bad: open on
3397-
// `manager:undefined` and wait for an admin (`admin_rescue`), kill the run
3398-
// (`fail`), or wave the record through (`auto_approve`). `fallback` adds the
3410+
// a slate nobody can act on and wait for an admin (`admin_rescue`), kill the
3411+
// run (`fail`), or wave the record through (`auto_approve`). `fallback` adds the
33993412
// fourth — open on someone who can actually decide.
34003413
//
3401-
// The rung is not special. Every graph approver type ends at the same literal;
3402-
// `manager` is just the one that reaches it without anybody authoring a wrong
3403-
// value, which is why it is the proving case here.
3414+
// The rung is not special: every approver type can resolve to nobody. `manager`
3415+
// is just the one that gets there without anybody authoring a wrong value,
3416+
// which is why it is the proving case here. Since #22558 it leaves an EMPTY
3417+
// slate rather than the dead `manager:undefined` literal, so the
3418+
// `admin_rescue` degradations below open on `[]`.
34043419
describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named rescue)", () => {
34053420
const svcWithWarnings = (engine: any) => {
34063421
const warnings: any[] = [];
@@ -3427,6 +3442,11 @@ describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named res
34273442
const OWNER_BACKSTOP = { type: 'user' as const, value: 'u_backstop' };
34283443
const said = (warnings: any[], needle: string) =>
34293444
warnings.some(([msg]) => String(msg).includes(needle));
3445+
// The fallback-FIRED warning. Not the bare policy spelling: the empty
3446+
// `manager` rung's own warning names `onEmptyApprovers: 'fallback'` as its
3447+
// remedy (#22558), so that needle would read "the fallback fired" off a
3448+
// warning that only recommends it.
3449+
const FALLBACK_FIRED = "per onEmptyApprovers: 'fallback'";
34303450

34313451
it('opens the request on the declared fallback, never on the manager:undefined literal', async () => {
34323452
const engine = makeFakeEngine();
@@ -3439,7 +3459,7 @@ describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named res
34393459
expect(req.status).toBe('pending');
34403460
expect(req.pending_approvers).toEqual(['u_backstop']);
34413461
expect(req.pending_approvers).not.toContain('manager:undefined');
3442-
expect(said(warnings, "onEmptyApprovers: 'fallback'")).toBe(true);
3462+
expect(said(warnings, FALLBACK_FIRED)).toBe(true);
34433463
// The request is decidable by a person, so the admin-takeover warning must
34443464
// NOT fire — it would be false about this request.
34453465
expect(said(warnings, 'decidable only by a privileged admin')).toBe(false);
@@ -3493,7 +3513,8 @@ describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named res
34933513
}), CTX) as any;
34943514

34953515
expect(req.status).toBe('pending');
3496-
expect(req.pending_approvers).toEqual(['manager:undefined']);
3516+
// The abandoned primary slate: the empty manager rung added no slot.
3517+
expect(req.pending_approvers).toEqual([]);
34973518
expect(said(warnings, 'resolved to nobody either')).toBe(true);
34983519
expect(said(warnings, 'decidable only by a privileged admin')).toBe(true);
34993520
});
@@ -3506,7 +3527,7 @@ describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named res
35063527
const req = await svc.openNodeRequest(managerNode({ onEmptyApprovers: 'fallback' }), CTX) as any;
35073528

35083529
expect(req.status).toBe('pending');
3509-
expect(req.pending_approvers).toEqual(['manager:undefined']);
3530+
expect(req.pending_approvers).toEqual([]);
35103531
expect(said(warnings, 'with no fallbackApprovers')).toBe(true);
35113532
expect(said(warnings, 'decidable only by a privileged admin')).toBe(true);
35123533
});
@@ -3526,7 +3547,7 @@ describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named res
35263547
} as any, CTX) as any;
35273548

35283549
expect(req.pending_approvers).toEqual(['u9']);
3529-
expect(said(warnings, "onEmptyApprovers: 'fallback'")).toBe(false);
3550+
expect(said(warnings, FALLBACK_FIRED)).toBe(false);
35303551
});
35313552

35323553
// ── negative controls ─────────────────────────────────────────────
@@ -3554,15 +3575,198 @@ describe("ApprovalService — onEmptyApprovers: 'fallback' (node-level named res
35543575
expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
35553576
});
35563577

3557-
it("'admin_rescue' still opens on the dead literal and warns", async () => {
3578+
it("'admin_rescue' still opens — on the empty slate — and warns", async () => {
35583579
const engine = makeFakeEngine();
35593580
const { svc, warnings } = svcWithWarnings(engine);
35603581
const req = await svc.openNodeRequest(managerNode({
35613582
onEmptyApprovers: 'admin_rescue', fallbackApprovers: [OWNER_BACKSTOP],
35623583
}), CTX) as any;
3563-
expect(req.pending_approvers).toEqual(['manager:undefined']);
3584+
expect(req.pending_approvers).toEqual([]);
3585+
expect(said(warnings, 'decidable only by a privileged admin')).toBe(true);
3586+
expect(said(warnings, FALLBACK_FIRED)).toBe(false);
3587+
});
3588+
});
3589+
3590+
// #22558 — a PERSON approver (`manager`, `field`) that resolves to nobody adds
3591+
// NO slot. Measured on 17.7.0 in a downstream app: a `{ type: 'manager' }` step
3592+
// for a record whose owner carries no `manager_id` opened with
3593+
// `pending_approvers: ["manager:undefined"]` on both read doors — a slot that
3594+
// reads as real, interpolates a missing value, and can be taken by nobody (the
3595+
// acting path admits only a user id, the caller's own email, or a held
3596+
// `position:<p>` address). The documented contract
3597+
// (`content/docs/automation/approvals.mdx`) is that an entry resolving to
3598+
// nobody contributes nothing and the node's `onEmptyApprovers` policy decides
3599+
// the empty slate.
3600+
//
3601+
// The directory is REAL here, not absent: the owner has a `sys_user` row whose
3602+
// `manager_id` is null — the exact shape the report measured — beside a second
3603+
// owner who does have one (the control).
3604+
describe('ApprovalService — a person approver that resolves to nobody adds no slot (#22558)', () => {
3605+
const svcWithWarnings = (engine: any) => {
3606+
const warnings: any[] = [];
3607+
let n = 0;
3608+
const svc = new ApprovalService({
3609+
engine,
3610+
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
3611+
logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
3612+
});
3613+
return { svc, warnings };
3614+
};
3615+
const said = (warnings: any[], needle: string) =>
3616+
warnings.some(([msg]) => String(msg).includes(needle));
3617+
3618+
const seedDirectory = (engine: any) => {
3619+
engine._tables['sys_user'] = [
3620+
{ id: 'u_no_mgr', manager_id: null },
3621+
{ id: 'u_has_mgr', manager_id: 'u_boss' },
3622+
{ id: 'u_boss', manager_id: null },
3623+
];
3624+
};
3625+
3626+
/** A node on a record owned by `owner`, with the given approvers / config. */
3627+
const node = (owner: string, approvers: any[], configExtra: Record<string, any> = {}) => ({
3628+
...openInput([]),
3629+
record: { id: 'opp1', amount: 100, owner_id: owner },
3630+
config: {
3631+
approvers,
3632+
behavior: 'first_response' as const,
3633+
lockRecord: false,
3634+
...configExtra,
3635+
},
3636+
});
3637+
3638+
it('a manager step for an owner with no manager_id opens on an EMPTY pending_approvers, and admin_rescue fires', async () => {
3639+
const engine = makeFakeEngine();
3640+
seedDirectory(engine);
3641+
const { svc, warnings } = svcWithWarnings(engine);
3642+
const req = await svc.openNodeRequest(node('u_no_mgr', [{ type: 'manager' }]), CTX) as any;
3643+
3644+
expect(req.status).toBe('pending');
3645+
expect(req.pending_approvers).toEqual([]);
3646+
// The stored row — what both read doors serve — holds no slot either.
3647+
const stored = String(engine._tables['sys_approval_request'][0].pending_approvers ?? '');
3648+
expect(stored.split(',').filter(Boolean)).toEqual([]);
3649+
// `onEmptyApprovers` defaults to admin_rescue, and it fired on this slate.
35643650
expect(said(warnings, 'decidable only by a privileged admin')).toBe(true);
3565-
expect(said(warnings, "onEmptyApprovers: 'fallback'")).toBe(false);
3651+
});
3652+
3653+
it("the same step under onEmptyApprovers: 'fallback' opens on the named people", async () => {
3654+
const engine = makeFakeEngine();
3655+
seedDirectory(engine);
3656+
const { svc } = svcWithWarnings(engine);
3657+
const req = await svc.openNodeRequest(node('u_no_mgr', [{ type: 'manager' }], {
3658+
onEmptyApprovers: 'fallback',
3659+
fallbackApprovers: [{ type: 'user', value: 'u_backstop' }],
3660+
}), CTX) as any;
3661+
3662+
expect(req.pending_approvers).toEqual(['u_backstop']);
3663+
});
3664+
3665+
it("control: an owner WITH a manager gets that manager's slot", async () => {
3666+
const engine = makeFakeEngine();
3667+
seedDirectory(engine);
3668+
const { svc, warnings } = svcWithWarnings(engine);
3669+
const req = await svc.openNodeRequest(node('u_has_mgr', [{ type: 'manager' }]), CTX) as any;
3670+
3671+
expect(req.pending_approvers).toEqual(['u_boss']);
3672+
expect(said(warnings, "approver 'manager' resolved to nobody")).toBe(false);
3673+
expect(said(warnings, 'decidable only by a privileged admin')).toBe(false);
3674+
});
3675+
3676+
// Beside other entries an empty manager rung contributes nothing, so the
3677+
// others carry the request — the "second entry that cannot resolve empty"
3678+
// escape the docs name for an unset manager. Under `unanimous` the dead
3679+
// literal used to be an unapprovable slot that held the request open after
3680+
// every real approver had approved.
3681+
it('beside a concrete approver, an empty manager rung adds nothing and does not hold a unanimous request open', async () => {
3682+
const engine = makeFakeEngine();
3683+
seedDirectory(engine);
3684+
const { svc } = svcWithWarnings(engine);
3685+
const req = await svc.openNodeRequest(node(
3686+
'u_no_mgr', [{ type: 'manager' }, { type: 'user', value: 'u9' }], { behavior: 'unanimous' },
3687+
), CTX) as any;
3688+
expect(req.pending_approvers).toEqual(['u9']);
3689+
3690+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
3691+
expect(out.finalized).toBe(true);
3692+
expect(out.request.status).toBe('approved');
3693+
});
3694+
3695+
// `field` is the other person type. On the open path the record is always
3696+
// present (the live re-read falls back to the trigger snapshot, then `{}`),
3697+
// so an empty field already contributed nothing; this pins that it still
3698+
// does, beside the manager change.
3699+
it('an empty field approver adds no slot either', async () => {
3700+
const engine = makeFakeEngine();
3701+
const { svc } = svcWithWarnings(engine);
3702+
const req = await svc.openNodeRequest(node('u_no_mgr', [{ type: 'field', value: 'reviewer' }]), CTX) as any;
3703+
expect(req.pending_approvers).toEqual([]);
3704+
});
3705+
3706+
// The kept half: a GROUP type's literal is untouched. For `position` the
3707+
// literal IS an acting address — a holder staffed in later decides it.
3708+
it('kept: an unstaffed position still opens on its position literal', async () => {
3709+
const engine = makeFakeEngine();
3710+
const { svc } = svcWithWarnings(engine);
3711+
const req = await svc.openNodeRequest(node('u_no_mgr', [{ type: 'position', value: 'cfo' }]), CTX) as any;
3712+
expect(req.pending_approvers).toEqual(['position:cfo']);
3713+
});
3714+
3715+
// ── stored rows: the disposition of a request opened before this fix ──
3716+
//
3717+
// A pending request a pre-fix release opened already holds the dead slot.
3718+
// Nothing rewrites it. Each pin below takes a request the current code
3719+
// opened on `[manager, user u9]` and puts the STORED row back into the
3720+
// pre-fix shape, then drives its next transition.
3721+
3722+
/** Open `[manager, user u9]` unanimous, then hand back the stored row. */
3723+
const openUnanimous = async (svc: ApprovalService, engine: any) => {
3724+
const req = await svc.openNodeRequest(node(
3725+
'u_no_mgr', [{ type: 'manager' }, { type: 'field', value: 'reviewer' }, { type: 'user', value: 'u9' }],
3726+
{ behavior: 'unanimous' },
3727+
), CTX) as any;
3728+
return { req, row: engine._tables['sys_approval_request'][0] };
3729+
};
3730+
3731+
it('stored row WITH the open-time slate snapshot keeps its dead slot: not rewritten, still an admin rescue', async () => {
3732+
const engine = makeFakeEngine();
3733+
seedDirectory(engine);
3734+
const { svc } = svcWithWarnings(engine);
3735+
const { req, row } = await openUnanimous(svc, engine);
3736+
// The pre-fix shape: the literal on the slate AND in the tally snapshot.
3737+
const cfg = JSON.parse(row.node_config_json);
3738+
cfg.__approverGroups = { 'manager:undefined': ['#0'], u9: ['#2'] };
3739+
row.node_config_json = JSON.stringify(cfg);
3740+
row.pending_approvers = 'manager:undefined,u9';
3741+
3742+
const vote = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
3743+
expect(vote.finalized).toBe(false);
3744+
expect(vote.request.pending_approvers).toEqual(['manager:undefined']);
3745+
3746+
// The recovery it always had: a privileged override decides it outright.
3747+
const rescue = await svc.decideNode(req.id, { decision: 'approve', actorId: 'admin_x' }, SYS);
3748+
expect(rescue.finalized).toBe(true);
3749+
expect(rescue.request.status).toBe('approved');
3750+
});
3751+
3752+
// A request opened before the tally snapshot existed is re-resolved at its
3753+
// next approve, against the stored payload — none here, so the person
3754+
// rungs have no record to read (the one way `field` reaches the no-slot
3755+
// branch). Both now add nothing, so the dead slots stop counting.
3756+
it('stored row WITHOUT a snapshot is re-evaluated at its next approve: the dead person slots stop counting', async () => {
3757+
const engine = makeFakeEngine();
3758+
seedDirectory(engine);
3759+
const { svc } = svcWithWarnings(engine);
3760+
const { req, row } = await openUnanimous(svc, engine);
3761+
const cfg = JSON.parse(row.node_config_json);
3762+
delete cfg.__approverGroups;
3763+
row.node_config_json = JSON.stringify(cfg);
3764+
row.payload_json = null;
3765+
row.pending_approvers = 'manager:undefined,u9';
3766+
3767+
const vote = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
3768+
expect(vote.finalized).toBe(true);
3769+
expect(vote.request.status).toBe('approved');
35663770
});
35673771
});
35683772

0 commit comments

Comments
 (0)