From 7d779e9851344a3ca93f2e3c2d120f89613a48a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:53:20 +0000 Subject: [PATCH 1/6] fix(plugin-approvals): a user approver naming no account expands to nobody A user approver's id is now checked against the directory with the one predicate the reassign door already asks. One that names no account (or names a user provably outside the organization) takes the graph types' expanded-to-nobody warning and adds no slot, so a slate it leaves empty reaches the node's onEmptyApprovers (default admin_rescue). Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- ...min-rescue-named-slate.integration.test.ts | 51 +++++++++ .../src/approval-service.test.ts | 108 ++++++++++++++---- .../plugin-approvals/src/approval-service.ts | 44 +++++-- 3 files changed, 171 insertions(+), 32 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts index 78ed3b15c4f..df83a508d58 100644 --- a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts @@ -291,6 +291,57 @@ describe('an empty admin_rescue slate opens on the organization\'s administrator ); }); + // A `user` approver whose value names no user expands to nobody, exactly as + // an unstaffed graph target does: the same per-slot warning, no slot of its + // own, and the node's onEmptyApprovers decides a slate this leaves empty — + // here the default admin_rescue. Before, the bare value went onto the slate + // as a slot no one could ever take, and nothing was logged. + describe('a `user` approver naming no user expands to nobody (#27397)', () => { + const PHANTOM = { type: 'user', value: 'usr_does_not_exist' }; + const phantomLine = "approver 'user:usr_does_not_exist' expanded to nobody"; + + it('alone, it takes the default admin_rescue route with the graph types\' warning, and holds no slot', async () => { + const before = warnings.length; + const row = await open('phantom_user', [PHANTOM]); + expect([...row.pending_approvers].sort()).toEqual(['u_admin', 'u_owner']); + expect(row.pending_approvers).not.toContain('usr_does_not_exist'); + expect(told(row.id)).toEqual(['u_admin', 'u_owner']); + const lines = warnings.slice(before); + expect(lines.filter((w) => w.includes(phantomLine))).toHaveLength(1); + expect(lines.filter((w) => w.includes("opening on this organization's administrators"))).toHaveLength(1); + expect((await snapshotOf(row.id)).__resolvedFrom['admin_rescue:abandoned']).toEqual([]); + }); + + it('beside a real user, only the phantom is dropped — and it is still named in the log', async () => { + const before = warnings.length; + const row = await open('phantom_mixed', [{ type: 'user', value: 'u_alice' }, PHANTOM]); + expect(row.pending_approvers).toEqual(['u_alice']); + expect(told(row.id)).toEqual(['u_alice']); + const lines = warnings.slice(before); + expect(lines.filter((w) => w.includes(phantomLine))).toHaveLength(1); + expect(lines.filter((w) => w.includes("opening on this organization's administrators"))).toEqual([]); + }); + + it('an email no account carries is dropped; one an account carries keeps its slot', async () => { + const row = await open('phantom_email', [ + { type: 'user', value: 'u_alice@example.com' }, { type: 'user', value: 'nobody@example.com' }, + ]); + expect(row.pending_approvers).toEqual(['u_alice@example.com']); + }); + + it('a user who is provably a member of another organization only is dropped too', async () => { + const row = await open('phantom_other_org', [{ type: 'user', value: 'u_other_owner' }]); + expect([...row.pending_approvers].sort()).toEqual(['u_admin', 'u_owner']); + }); + + it('CONTROL: a real user keeps the slot and no expanded-to-nobody line is logged', async () => { + const before = warnings.length; + const row = await open('real_user', [{ type: 'user', value: 'u_alice' }]); + expect(row.pending_approvers).toEqual(['u_alice']); + expect(warnings.slice(before).filter((w) => w.includes('expanded to nobody'))).toEqual([]); + }); + }); + // Last of the cases whose slate is `org_a`'s administrators: the delegation // it writes stays, and no case after it reads that slate. it('an administrator out of office is covered by their delegate, as any named approver is', async () => { diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 64d2a4119c5..78f4d09a659 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -18,8 +18,19 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; interface FakeRow { [k: string]: any } +/** + * The accounts this suite routes `user` approver slots to. A `user` approver + * holds a slot only when an account carries its id or email (#27397), so the + * double's directory starts with them. Ids a test needs ABSENT — the reassign + * and escalation refusals' targets — are deliberately not on it. + */ +const ROUTED_USERS = [ + 'u1', 'u2', 'u3', 'u5', 'u9', 'alice', 'bob', 'carol', 'dave', 'u_backstop', 'u_old', 'u_holder', 'someone-else', + 'l1', 'l2', 'f1', 'f2', 'a1', 'a2', 'lb', +]; + function makeFakeEngine() { - const tables: Record = {}; + const tables: Record = { sys_user: ROUTED_USERS.map((id) => ({ id })) }; const ensure = (n: string) => (tables[n] ??= []); const hooks: Record any | Promise; object?: string | string[]; packageId?: string }>> = {}; @@ -200,6 +211,20 @@ function staffDirectory(engine: { _tables: Record }, ...ids: for (const id of ids) if (!users.some(u => u.id === id)) users.push({ id }); } +/** + * Put `rows` into the double's `sys_user`, each replacing any row with its id — + * the {@link ROUTED_USERS} roster included, so a test that names an account + * (a display name, an email, a manager) reads the row it wrote. + */ +function putAccounts(engine: { _tables: Record }, ...rows: FakeRow[]): void { + const users = (engine._tables['sys_user'] ??= []); + for (const row of rows) { + const at = users.findIndex(u => u.id === row.id); + if (at >= 0) users.splice(at, 1); + users.push(row); + } +} + function nodeConfig(approvers: string[], extra: Record = {}) { return { approvers: approvers.map(v => ({ type: 'user' as const, value: v })), @@ -1320,7 +1345,7 @@ describe('ApprovalService (node era)', () => { it('listRequests enriches record_title and submitter_name', async () => { engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', amount: 100 }]; - engine._tables['sys_user'] = [{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }]; + putAccounts(engine, { id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }); await svc.openNodeRequest(openInput(['u9']), CTX); // submitter_id = u1 (CTX.userId) const rows = await svc.listRequests({ status: 'pending' }, SYS); expect(rows[0].record_title).toBe('Acme Renewal'); @@ -1499,17 +1524,17 @@ describe('ApprovalService (node era)', () => { }); it('enrichment maps user-id approvers to display names', async () => { - engine._tables['sys_user'] = [{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }]; + putAccounts(engine, { id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }); await svc.openNodeRequest(openInput(['u9']), CTX); const rows = await svc.listRequests({ status: 'pending' }, SYS); expect(rows[0].pending_approver_names).toEqual({ u9: 'Grace Hopper' }); }); it('listActions resolves actor display names', async () => { - engine._tables['sys_user'] = [ + putAccounts(engine, { id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }, { id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }, - ]; + ); const req = await svc.openNodeRequest(openInput(['u9']), CTX); // Decided by u9 THEMSELF: `actor_id` records the person the context // vouches for (#21411), and a bare system context vouches for nobody. @@ -1544,10 +1569,10 @@ describe('ApprovalService (node era)', () => { }); it('reassign: listActions resolves the hand-off parties to display names (#4365)', async () => { - engine._tables['sys_user'] = [ + putAccounts(engine, { id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }, { id: 'u7', name: 'Ada Lovelace', email: 'ada@example.com' }, - ]; + ); const req = await svc.openNodeRequest(openInput(['u9']), CTX); await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9')); const actions = await svc.listActions(req.id, SYS); @@ -1634,7 +1659,7 @@ describe('ApprovalService (node era)', () => { }); it('refuses an email no account carries', async () => { - engine._tables['sys_user'] = [{ id: 'u7', email: 'ada@example.com' }]; + putAccounts(engine, { id: 'u7', email: 'ada@example.com' }); const id = await openOn(['u9']); await expectRefusedUnchanged( id, svc.reassign(id, { actorId: 'u9', to: 'grace@example.com' }, asUser('u9')), @@ -1643,7 +1668,7 @@ describe('ApprovalService (node era)', () => { }); it('refuses a user who is provably a member of other organizations only, in the same words as a missing user', async () => { - engine._tables['sys_user'] = [{ id: 'u_other' }]; + putAccounts(engine, { id: 'u_other' }); engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u_other', organization_id: 't2' }]; const id = await openOn(['u9']); const missing = await svc.reassign(id, { actorId: 'u9', to: 'u_missing' }, asUser('u9')).catch((e: Error) => e.message); @@ -1690,7 +1715,7 @@ describe('ApprovalService (node era)', () => { }); it('moves the slot to a user id of a member here, and to one with no membership rows at all (no tenancy fact, as the sibling screens read it)', async () => { - engine._tables['sys_user'] = [{ id: 'u7' }, { id: 'u8' }]; + putAccounts(engine, { id: 'u7' }, { id: 'u8' }); engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u7', organization_id: 't1' }, { id: 'm2', user_id: 'u7', organization_id: 't2' }]; const id = await openOn(['u9', 'u2']); const first = await svc.reassign(id, { actorId: 'u9', to: 'u7' }, asUser('u9')); @@ -1701,7 +1726,7 @@ describe('ApprovalService (node era)', () => { }); it('moves the slot to an email an account carries, in the spelling the account stores', async () => { - engine._tables['sys_user'] = [{ id: 'u7', email: 'ada@example.com' }]; + putAccounts(engine, { id: 'u7', email: 'ada@example.com' }); const id = await openOn(['u9']); const out = await svc.reassign(id, { actorId: 'u9', to: 'ada@example.com' }, asUser('u9')); expect(out.request.pending_approvers).toEqual(['ada@example.com']); @@ -1844,6 +1869,7 @@ describe('ApprovalService (node era)', () => { it('remind: each concrete approver gets their own reminder, and none carries an action link (#24564)', async () => { const emitted: any[] = []; svc.attachMessaging({ async emit(input) { emitted.push(input); } }); + putAccounts(engine, { id: 'u_ada', email: 'ada@example.com' }); const reqId = (await svc.openNodeRequest(openInput(['u9', 'ada@example.com']), CTX)).id; const minted = (engine._tables['sys_approval_token'] ?? []).length; await svc.remind(reqId, { actorId: 'u1' }, CTX); @@ -1919,6 +1945,7 @@ describe('ApprovalService (node era)', () => { .map(r => ({ request_id: r.request_id, approver: r.approver })); it('openNodeRequest mirrors every approver identity into the index', async () => { + putAccounts(engine, { id: 'u_ada', email: 'ada@example.com' }); const req = await svc.openNodeRequest(openInput(['u9', 'ada@example.com', 'role:finance']), CTX); expect(indexRows()).toEqual([ { request_id: req.id, approver: 'u9' }, @@ -2205,7 +2232,7 @@ describe('ApprovalService (node era)', () => { }); it('keeps the slate when escalateTo is a user whose only membership is another organization', async () => { - engine._tables['sys_user'] = [{ id: 'u_far' }]; + putAccounts(engine, { id: 'u_far' }); engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u_far', organization_id: 't2' }]; const id = await overdueReassign('u_far'); await expectKeptAndNotified(id, 'u_far', 'it names no user in this request\'s organization.'); @@ -2236,7 +2263,7 @@ describe('ApprovalService (node era)', () => { }); it('still reassigns to an account\'s user id, its email, and a position:

someone holds here', async () => { - engine._tables['sys_user'] = [{ id: 'boss', email: 'boss@example.com' }]; + putAccounts(engine, { id: 'boss', email: 'boss@example.com' }); engine._tables['sys_member'] = [{ id: 'm1', user_id: 'boss', organization_id: 't1' }]; engine._tables['sys_user_position'] = [ { id: 'up1', user_id: 'u5', position: 'finance', organization_id: 't1' }, @@ -2395,7 +2422,7 @@ describe('ApprovalService (node era)', () => { }); it('enrichment resolves an email submitter via sys_user.email', async () => { - engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }]; + putAccounts(engine, { id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }); await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX); const rows = await svc.listRequests({ status: 'pending' }, SYS); expect(rows[0].submitter_name).toBe('Grace Hopper'); @@ -3367,7 +3394,7 @@ describe('ApprovalService — out-of-office delegation (#1322)', () => { }); it('type:manager — reroutes when the resolved manager is out of office', async () => { - engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }]; + putAccounts(engine, { id: 'carol', manager_id: 'alice' }); seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]); const input = { ...openInput([]), @@ -3929,8 +3956,37 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38 expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]); }); - it('stays quiet for `user` — a literal id was never a lookup that could come back empty', async () => { + // #27397: a `user` id no account carries is the graph types' dead end in + // another spelling — the same warning, then NO slot (the bare id is a slot + // no one can take), so the node's onEmptyApprovers decides the slate. + it('`user`: an id no account carries warns as the graph types do, and adds no slot', async () => { + const engine = makeFakeEngine(); + const { svc, warnings } = svcWithWarnings(engine); + const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX); + + expect(req.pending_approvers).not.toContain('u_unknown'); + expect(req.pending_approvers).not.toContain('user:u_unknown'); + const hit = warnings.find(([msg]) => String(msg).includes("approver 'user:u_unknown' expanded to nobody")); + expect(hit, 'no warning for user').toBeTruthy(); + expect(hit[1]).toMatchObject({ type: 'user', value: 'u_unknown', organizationId: 't1' }); + }); + + it('stays quiet for a `user` an account carries', async () => { + const engine = makeFakeEngine(); + const { svc, warnings } = svcWithWarnings(engine); + const req = await svc.openNodeRequest(approverInput('user', 'u9'), CTX); + + expect(req.pending_approvers).toEqual(['u9']); + expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]); + }); + + it('a failed directory read keeps the `user` slot: it proves no one is missing', async () => { const engine = makeFakeEngine(); + const find = engine.find.bind(engine); + engine.find = async (object: string, options?: any) => { + if (object === 'sys_user') throw new Error('directory down'); + return find(object, options); + }; const { svc, warnings } = svcWithWarnings(engine); const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX); @@ -4150,11 +4206,11 @@ describe('ApprovalService — a person approver that resolves to nobody adds no warnings.some(([msg]) => String(msg).includes(needle)); const seedDirectory = (engine: any) => { - engine._tables['sys_user'] = [ + putAccounts(engine, { id: 'u_no_mgr', manager_id: null }, { id: 'u_has_mgr', manager_id: 'u_boss' }, { id: 'u_boss', manager_id: null }, - ]; + ); }; /** A node on a record owned by `owner`, with the given approvers / config. */ @@ -4816,8 +4872,8 @@ describe('ApprovalService — every slot reader takes the acting addresses (#213 it('the email-keyed slot (a `user` approver authored as an email): listed, served with can_act, decided by the default actor, and kept in sight', async () => { const engine = makeFakeEngine(); - await engine.insert('sys_user', { id: 'u_mail', email: 'mail.reviewer@example.com' }); - await engine.insert('sys_user', { id: 'u_other', email: 'other@example.com' }); + putAccounts(engine, { id: 'u_mail', email: 'mail.reviewer@example.com' }); + putAccounts(engine, { id: 'u_other', email: 'other@example.com' }); const svc = svcFor(engine); const MAIL = holding('u_mail', []); const OTHER = holding('u_other', []); @@ -5052,7 +5108,7 @@ describe('ApprovalService — actor_id records the person, acted_as the slot (#2 it('the three slot kinds, through the session door: position, email and user id', async () => { const engine = makeFakeEngine(); - await engine.insert('sys_user', { id: 'u_mail', email: 'mail.reviewer@example.com' }); + putAccounts(engine, { id: 'u_mail', email: 'mail.reviewer@example.com' }); const svc = svcFor(engine); // Position — named, exactly the card's call. @@ -5073,7 +5129,7 @@ describe('ApprovalService — actor_id records the person, acted_as the slot (#2 it('the action link: a user-id token records that user, an email-bound token the one account carrying it — or nobody', async () => { const engine = makeFakeEngine(); - await engine.insert('sys_user', { id: 'u_mail', email: 'mail.reviewer@example.com' }); + putAccounts(engine, { id: 'u_mail', email: 'mail.reviewer@example.com' }); const svc = svcFor(engine); const user = await open(svc, [{ type: 'user', value: 'u9' }]); @@ -5085,9 +5141,13 @@ describe('ApprovalService — actor_id records the person, acted_as the slot (#2 expect(out).toMatchObject({ ok: true, action: 'approve' }); expect(recorded(actionsOf(engine, mail.id, 'approve')[0])).toEqual(['u_mail', 'mail.reviewer@example.com']); - // No account carries the email: the slot is still taken (the token admits + // No account carries the email any more — it did when the request opened + // (#27397: a `user` approver naming no account adds no slot), and is gone + // by the time the link is used: the slot is still taken (the token admits // it), and the person is unknown — `null`, ADR-0118's value, never the email. + putAccounts(engine, { id: 'u_outside', email: 'outside@example.com' }); const stranger = await open(svc, [{ type: 'user', value: 'outside@example.com' }]); + engine._tables['sys_user'] = engine._tables['sys_user'].filter(u => u.id !== 'u_outside'); await svc.redeemActionToken((await svc.issueActionTokens(stranger.id, 'outside@example.com')).approve); const [act] = actionsOf(engine, stranger.id, 'approve'); expect(recorded(act)).toEqual([null, 'outside@example.com']); @@ -5161,7 +5221,7 @@ describe('ApprovalService — actor_id records the person, acted_as the slot (#2 it('the action log shows the person by name and the slot beside it — and a backfilled row the slot alone', async () => { const engine = makeFakeEngine(); - await engine.insert('sys_user', { id: 'u_holder', name: 'Hana Holder', email: 'hana@example.com' }); + putAccounts(engine, { id: 'u_holder', name: 'Hana Holder', email: 'hana@example.com' }); const svc = svcFor(engine); const req = await open(svc, [...toPosition, { type: 'user', value: 'u9' }], 'unanimous'); await svc.decideNode(req.id, { decision: 'approve' } as any, HOLDER); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 1aa18510500..1e3c8a18809 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -1093,8 +1093,11 @@ const MEMBER_SCREEN_READ_LIMIT = 50000; * back to a `type:value` literal that no user can act on. They are listed here * so that dead end gets one warning instead of passing in silence. * - * `user` / `field` are deliberately absent: they resolve to the id they were - * given without a lookup, so there is no "expanded to nobody" state to report. + * `field` is deliberately absent: it resolves to the ids the record holds + * without a lookup, so there is no "expanded to nobody" state to report. + * `user` is absent from the SET but not from the warning (#27397): its id is + * checked against the directory, and one that names no one takes this same + * warning — then adds no slot, as a person type does. * `manager` is absent too: it names a PERSON, so an empty lookup adds no slot at * all ({@link PERSON_APPROVER_TYPES}) and has its own warning. * `business_unit` / `bu` are the accepted dialects of `department`. @@ -1122,7 +1125,9 @@ const GRAPH_APPROVER_TYPES: ReadonlySet = new Set([ * other entries carry the request — the "second entry that cannot resolve * empty" escape that page names for an unset manager. * - * `user` needs no entry: its `value` IS the slot and it never falls back. + * `user` needs no entry: its `value` IS the slot when it names an account, + * and it never falls back to a literal — one that names no account adds no + * slot and warns as the graph types do (#27397). * `position` keeps its literal because the literal is an acting address — a * holder staffed into the position later decides it, on a slate that also * names a person. A slate of literals alone names no one, and under the @@ -2380,7 +2385,8 @@ export class ApprovalService implements IApprovalService { * resolved ids with a group (#3266) without duplicating the resolution logic. * Returns the `type:value` literal as a single-element fallback when a GROUP * lookup yields nothing, and an empty list when a PERSON type - * ({@link PERSON_APPROVER_TYPES}) does (#22558). + * ({@link PERSON_APPROVER_TYPES}) does (#22558) or a `user` names no + * account (#27397). */ private async resolveApproverSpec( a: any, @@ -2420,7 +2426,24 @@ export class ApprovalService implements IApprovalService { const crossOrg = directoryOrg !== organizationId; if (type === 'user') { - return this.applyOooDelegation(String(a.value), now, organizationId, substitutions); + // #27397: the value is the slot only when someone can take it — the ONE + // "does this address resolve to a person" predicate the reassign door + // asks ({@link reassignTargetRefusal}): a `sys_user` row carries the id + // (or the email, as stored), and that user is not provably a member of + // other organizations only. A banned account still resolves, as a banned + // team or unit member does. One that resolves to no one falls through to + // the graph types' "expanded to nobody" warning below and adds no slot, + // so a slate it leaves empty reaches the node's onEmptyApprovers. A + // failed read proves nothing, so the slot stays: this file's + // addressing-path posture (fail open, never empty a live slate on a + // hiccup). A value spelled as a `type:value` literal is not asked: + // `slateNamesAPerson` already reads it as naming no one. + const value = String(a.value); + let refusal: string | undefined; + if (!value.includes(':')) { + try { refusal = await this.reassignTargetRefusal(value, organizationId ?? null); } catch { refusal = undefined; } + } + if (refusal === undefined) return this.applyOooDelegation(value, now, organizationId, substitutions); } if (type === 'field' && record) { // #3447: a record field can name MANY approvers — a multi-select user @@ -2537,7 +2560,7 @@ export class ApprovalService implements IApprovalService { ); return []; } - if (GRAPH_APPROVER_TYPES.has(type)) { + if (GRAPH_APPROVER_TYPES.has(type) || type === 'user') { // #3807 follow-up — every OTHER way to land here is a graph type whose // lookup produced nobody, and the literal below is a slot no user can // ever act on. That silence is what let #3807 hide: a `department` @@ -2564,6 +2587,10 @@ export class ApprovalService implements IApprovalService { { type, value: a.value, organizationId: organizationId ?? null }, ); } + // #27397: a `user` that resolved to no one adds no slot — `user:` + // is no acting address (`approver-address.ts`), and the bare value is the + // phantom slot the existence check above exists to keep off the slate. + if (type === 'user') return []; return [`${type}:${a.value}`]; } @@ -5923,8 +5950,9 @@ export class ApprovalService implements IApprovalService { /** * Why `to` resolves to no one in `organizationId` — the sentence a refusal * carries — or `undefined` when someone can take a slot under it. The ONE - * predicate behind {@link assertReassignTargetResolves} (the door) and - * {@link escalateRequest}'s `reassign` (the SLA sweep, #25598); its contract + * predicate behind {@link assertReassignTargetResolves} (the door), + * {@link escalateRequest}'s `reassign` (the SLA sweep, #25598) and a `user` + * approver's resolution in {@link resolveApproverSpec} (#27397); its contract * is that docblock's. */ private async reassignTargetRefusal(to: string, organizationId: string | null): Promise { From b4e2a27fd48b027c78fbeee8027fbf8ab4ecdab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:07:15 +0000 Subject: [PATCH 2/6] test(plugin-approvals): staff the doubles' directories with the accounts their user approvers name Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../27397-user-approver-names-no-account.md | 19 ++++++++++ .../src/admin-exemption-retired.test.ts | 11 +++++- ...min-rescue-named-slate.integration.test.ts | 6 +-- .../src/approval-actor-impersonation.test.ts | 9 ++++- .../src/approval-node.test.ts | 9 ++++- .../src/approval-override-audit.test.ts | 11 +++++- .../src/approval-restart-resume.test.ts | 9 ++++- .../src/approval-revise.test.ts | 9 ++++- .../plugin-approvals/src/approval-service.ts | 38 +++++++++++-------- .../src/deleted-flow-decision-refusal.test.ts | 9 ++++- .../src/ooo-delegation-organization.test.ts | 9 ++++- ...ng-approver-read-grant.integration.test.ts | 2 + .../restored-approval-continuation.test.ts | 9 ++++- .../status-mirror-cascade.integration.test.ts | 2 + 14 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 .changeset/27397-user-approver-names-no-account.md diff --git a/.changeset/27397-user-approver-names-no-account.md b/.changeset/27397-user-approver-names-no-account.md new file mode 100644 index 00000000000..74fad6b4d34 --- /dev/null +++ b/.changeset/27397-user-approver-names-no-account.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +fix(plugin-approvals): a `user` approver whose id or email no account carries expands to nobody, warns, and takes the node's `onEmptyApprovers` route instead of opening on a slot no one can take + +Clause-②: no +Metadata-protocol: none + +A `{ type: 'user', value }` approver used to put `value` on the slate as it was written, without asking whether any account carries it. A mistyped id, or the id of a person removed since the flow was authored, opened a request pending on that slot: no one could ever decide it, it appeared in no one's queue, and nothing was logged. + +Now the value is checked against `sys_user` (by id, or by email for an email-shaped value), with the same predicate the reassign door already uses. When no account carries it: + +- the approver logs the same `approver 'user:' expanded to nobody` warning the team, department, position and membership-tier approvers log; +- it adds no slot to the request; +- a slate this leaves empty is decided by the node's `onEmptyApprovers`, by default `admin_rescue`, which opens the request on the organization's administrators; +- beside other approvers, only the unresolvable one is dropped, and the others carry the request. + +Unchanged: a real account keeps its slot, including a banned one and a person from another organization's directory (a `user` approver names a person outright, so no tenancy screen is applied). An out-of-office delegation that routes the approver to a delegate still routes. A failed `sys_user` read keeps the slot, because it does not prove that the account is missing. diff --git a/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts b/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts index 918165da754..0a630b24d63 100644 --- a/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts +++ b/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts @@ -71,9 +71,16 @@ import { bindApprovalLockHook, bindDelegationWriteGuard } from './lifecycle-hook interface FakeRow { [k: string]: any } +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u9']; + /** The same minimal engine shape `approval-service.test.ts` uses. */ function makeFakeEngine() { - const tables: Record = {}; + const tables: Record = { sys_user: ROUTED_USERS.map((id) => ({ id })) }; const ensure = (n: string) => (tables[n] ??= []); const hooks: Record any; object?: string | string[]; packageId?: string }>> = {}; @@ -253,7 +260,7 @@ describe('#4839 evidence — the sanctioned admin path covers an unavailable app bindApprovalLockHook(engine as any); engine._tables['opportunity'] = [{ id: 'opp1', amount: 100, stage: 'new' }]; // The substitute has an account: a reassign target must name one (#25485). - engine._tables['sys_user'] = [{ id: 'u7' }]; + engine._tables['sys_user'].push({ id: 'u7' }); }); /** An ordinary member's edit of the approval's target record. */ diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts index df83a508d58..0851f266d24 100644 --- a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts @@ -329,9 +329,9 @@ describe('an empty admin_rescue slate opens on the organization\'s administrator expect(row.pending_approvers).toEqual(['u_alice@example.com']); }); - it('a user who is provably a member of another organization only is dropped too', async () => { - const row = await open('phantom_other_org', [{ type: 'user', value: 'u_other_owner' }]); - expect([...row.pending_approvers].sort()).toEqual(['u_admin', 'u_owner']); + it('CONTROL: a person of another organization\'s directory, named outright, keeps the slot (ADR-0105 D9)', async () => { + const row = await open('other_org_user', [{ type: 'user', value: 'u_other_owner' }]); + expect(row.pending_approvers).toEqual(['u_other_owner']); }); it('CONTROL: a real user keeps the slot and no expanded-to-nobody line is logged', async () => { diff --git a/packages/plugins/plugin-approvals/src/approval-actor-impersonation.test.ts b/packages/plugins/plugin-approvals/src/approval-actor-impersonation.test.ts index 681cd564866..d466e077fab 100644 --- a/packages/plugins/plugin-approvals/src/approval-actor-impersonation.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-actor-impersonation.test.ts @@ -32,9 +32,16 @@ import { ApprovalService, SLA_ACTOR_ID } from './approval-service.js'; interface FakeRow { [k: string]: any } +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['bob', 'carol']; + /** Equality/`$in`/`$ne`/`$contains` WHERE matcher — mirrors approval-service.test.ts. */ function makeFakeEngine() { - const tables: Record = {}; + const tables: Record = { sys_user: ROUTED_USERS.map((id) => ({ id })) }; const ensure = (n: string) => (tables[n] ??= []); function matches(row: FakeRow, filter: any): boolean { diff --git a/packages/plugins/plugin-approvals/src/approval-node.test.ts b/packages/plugins/plugin-approvals/src/approval-node.test.ts index 4cdb14ff901..e8f9b58f786 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.test.ts @@ -11,6 +11,13 @@ const noopLogger = { info() {}, warn() {}, error() {}, debug() {}, }; +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u1', 'u2', 'u9', 'lead']; + /** * Tiny in-memory ObjectQL stand-in — supports the `where`-equality + `$in` * queries the approval service issues, enough to drive the node bridge. @@ -23,7 +30,7 @@ function makeFakeEngine( // the documented degradation, exercised below rather than assumed. schemas?: Record, ) { - const tables = new Map(); + const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`); diff --git a/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts b/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts index d53c44a07ed..f1171e84e09 100644 --- a/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-override-audit.test.ts @@ -24,9 +24,16 @@ import { ApprovalService } from './approval-service.js'; interface FakeRow { [k: string]: any } +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u9']; + /** The same minimal engine shape `approval-service.test.ts` uses. */ function makeFakeEngine() { - const tables: Record = {}; + const tables: Record = { sys_user: ROUTED_USERS.map((id) => ({ id })) }; const ensure = (n: string) => (tables[n] ??= []); function matches(row: FakeRow, filter: any): boolean { if (!filter || typeof filter !== 'object') return true; @@ -103,7 +110,7 @@ describe('approval override audit marker (#4466)', () => { clock: { now: () => new Date(baseTime + (n++) * 1000) }, }); // The reassign target has an account: a reassign target must name one (#25485). - engine._tables['sys_user'] = [{ id: 'u7' }]; + engine._tables['sys_user'].push({ id: 'u7' }); }); /** A request whose slate is properly STAFFED — one real, designated user. */ diff --git a/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts b/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts index cb4ed8c46b7..bb352430792 100644 --- a/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts @@ -34,9 +34,16 @@ import { registerApprovalNode } from './approval-node.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u1']; + /** In-memory ObjectQL stand-in — the approvals tables outlive the "restart". */ function makeFakeEngine() { - const tables = new Map(); + const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`); diff --git a/packages/plugins/plugin-approvals/src/approval-revise.test.ts b/packages/plugins/plugin-approvals/src/approval-revise.test.ts index f93013dc690..004eeb97a29 100644 --- a/packages/plugins/plugin-approvals/src/approval-revise.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-revise.test.ts @@ -33,9 +33,16 @@ const asUser = (userId: string) => const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u1', 'u2']; + /** In-memory ObjectQL stand-in: equality/$in where, orderBy, limit. */ function makeFakeEngine() { - const tables = new Map(); + const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 1e3c8a18809..ae44df7ffed 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -2426,24 +2426,30 @@ export class ApprovalService implements IApprovalService { const crossOrg = directoryOrg !== organizationId; if (type === 'user') { - // #27397: the value is the slot only when someone can take it — the ONE - // "does this address resolve to a person" predicate the reassign door - // asks ({@link reassignTargetRefusal}): a `sys_user` row carries the id - // (or the email, as stored), and that user is not provably a member of - // other organizations only. A banned account still resolves, as a banned - // team or unit member does. One that resolves to no one falls through to - // the graph types' "expanded to nobody" warning below and adds no slot, - // so a slate it leaves empty reaches the node's onEmptyApprovers. A - // failed read proves nothing, so the slot stays: this file's - // addressing-path posture (fail open, never empty a live slate on a - // hiccup). A value spelled as a `type:value` literal is not asked: - // `slateNamesAPerson` already reads it as naming no one. + // #27397: the value is the slot only when an account carries it — asked + // through the ONE "does this address resolve to a person" predicate the + // reassign door asks ({@link reassignTargetRefusal}): a `sys_user` row + // carries the id (or the email, as stored). Asked with NO organization, + // so the predicate's tenancy screen does not run: `user` is + // directory-less and ADR-0105 D9 leaves it untouched — a person of + // another organization's directory named outright stays named. A banned + // account still resolves, as a banned team or unit member does (neither + // expansion reads the flag). What is asked is the slot the route ENDS + // on: an out-of-office hop that carries the request to a delegate has + // named someone (a removed account's delegation still routes, #24065). + // One that resolves to no one falls through to the graph types' + // "expanded to nobody" warning below and adds no slot, so a slate it + // leaves empty reaches the node's onEmptyApprovers. A failed read proves + // nothing, so the slot stays: this file's addressing-path posture (fail + // open, never empty a live slate on a hiccup). A value spelled as a + // `type:value` literal is not asked: `slateNamesAPerson` already reads it + // as naming no one. const value = String(a.value); + const routed = await this.applyOooDelegation(value, now, organizationId, substitutions); + if (routed.length !== 1 || routed[0] !== value || value.includes(':')) return routed; let refusal: string | undefined; - if (!value.includes(':')) { - try { refusal = await this.reassignTargetRefusal(value, organizationId ?? null); } catch { refusal = undefined; } - } - if (refusal === undefined) return this.applyOooDelegation(value, now, organizationId, substitutions); + try { refusal = await this.reassignTargetRefusal(value, null); } catch { refusal = undefined; } + if (refusal === undefined) return routed; } if (type === 'field' && record) { // #3447: a record field can name MANY approvers — a multi-select user diff --git a/packages/plugins/plugin-approvals/src/deleted-flow-decision-refusal.test.ts b/packages/plugins/plugin-approvals/src/deleted-flow-decision-refusal.test.ts index 2a3a1c5d446..d68230d69bd 100644 --- a/packages/plugins/plugin-approvals/src/deleted-flow-decision-refusal.test.ts +++ b/packages/plugins/plugin-approvals/src/deleted-flow-decision-refusal.test.ts @@ -36,13 +36,20 @@ import { registerApprovalNode } from './approval-node.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u1']; + /** * In-memory ObjectQL stand-in for the approvals tables: the pinned double of * `decision-strand-envelope.test.ts`, copied whole (bound by presence, writes * through the engine-dispatch assertions). */ function makeFakeEngine() { - const tables = new Map(); + const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`); diff --git a/packages/plugins/plugin-approvals/src/ooo-delegation-organization.test.ts b/packages/plugins/plugin-approvals/src/ooo-delegation-organization.test.ts index 9aaf5f0747e..2d1626f41d1 100644 --- a/packages/plugins/plugin-approvals/src/ooo-delegation-organization.test.ts +++ b/packages/plugins/plugin-approvals/src/ooo-delegation-organization.test.ts @@ -28,8 +28,15 @@ import { ApprovalService } from './approval-service.js'; interface Row { [k: string]: any } +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['alice', 'carol']; + function makeEngine(seed: Record = {}) { - const tables: Record = { ...seed }; + const tables: Record = { sys_user: ROUTED_USERS.map((id) => ({ id })), ...seed }; const ensure = (n: string) => (tables[n] ??= []); const matches = (row: Row, filter: any): boolean => { if (!filter || typeof filter !== 'object') return true; diff --git a/packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts b/packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts index d7e66073699..d0eb60a7f7e 100644 --- a/packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts @@ -117,6 +117,8 @@ async function boot(): Promise { await installDefaultOrganization(engine); await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: OTHER_ORG, name: 'Other' }); await engine.getDriverForObject('sys_user')!.create('sys_user', { id: BY_EMAIL, email: BY_EMAIL_ADDRESS }); + // A `user` approver holds a slot only when an account carries it (#27397). + await engine.getDriverForObject('sys_user')!.create('sys_user', { id: BY_ID }); const services: Record = { objectql: engine }; const ctx: any = { diff --git a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts index ffe2516fa21..fa9d7183132 100644 --- a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts +++ b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts @@ -53,9 +53,16 @@ import { registerApprovalReviseNode } from './approval-revise-node.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; +/** + * The accounts this suite routes `user` approver slots to: a `user` approver + * holds a slot only when an account carries it (#27397), so the double's + * directory starts with them. + */ +const ROUTED_USERS = ['u1', 'u2']; + /** In-memory ObjectQL stand-in for the approvals tables. */ function makeFakeEngine() { - const tables = new Map(); + const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); // ⭐ The lever that makes a REAL strand reachable from a test: fail the very // next insert into one table, once. The approval node's executor opens the diff --git a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts index 76f82cfa899..7dea9b543c9 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts @@ -355,6 +355,8 @@ describe('an approval decision cascades as the deciding user (#3783)', () => { objectql.registry.registerObject(SysApprovalDelegation as any, 'approvals-test', 'approvals-test'); automation.registerFlow('on_approved', onApprovedFlow as any); + // A `user` approver holds a slot only when an account carries it (#27397). + await data.insert('sys_user', { id: 'approver', email: 'approver@example.test' }, { context: { isSystem: true } }); svc = new ApprovalService({ engine: objectql }); await data.insert('opportunity', { id: 'opp1', amount: 100 }, { context: { isSystem: true } }); }); From b1247b793dd7ff09cbef3fade4e98a6455a12c76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:18:59 +0000 Subject: [PATCH 3/6] test(dogfood): the co-approver and departed-approver addresses are accounts that never act Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../test/approval-override-composite-pin.dogfood.test.ts | 2 ++ .../dogfood/test/fixtures/my-pending-position-fixture.ts | 7 ++++++- .../qa/dogfood/test/fixtures/override-composite-fixture.ts | 7 ++++++- .../test/fixtures/position-address-readers-fixture.ts | 7 ++++++- .../test/my-pending-position-address.dogfood.test.ts | 1 + .../dogfood/test/position-address-readers.dogfood.test.ts | 1 + 6 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/approval-override-composite-pin.dogfood.test.ts b/packages/qa/dogfood/test/approval-override-composite-pin.dogfood.test.ts index 8eef69ed9cb..a09ccaac707 100644 --- a/packages/qa/dogfood/test/approval-override-composite-pin.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-override-composite-pin.dogfood.test.ts @@ -112,6 +112,8 @@ describe('approval override composite (#16679)', () => { // `{can_act:false, is_submitter:false, can_override:true}` triple the // #16679 measurement round read off the wire. const memberToken = await stack.signUp('override-composite-member@example.com'); + // The departed approver's account exists, and its holder never acts. + await stack.signUp(DEPARTED_APPROVER); const createRes = await stack.apiAs(memberToken, 'POST', '/data/override_composite_request', { name: 'Stranded request', amount: 100, diff --git a/packages/qa/dogfood/test/fixtures/my-pending-position-fixture.ts b/packages/qa/dogfood/test/fixtures/my-pending-position-fixture.ts index 6df5520e671..9dde732ab0a 100644 --- a/packages/qa/dogfood/test/fixtures/my-pending-position-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/my-pending-position-fixture.ts @@ -30,7 +30,12 @@ export const ROUTED_POSITION = 'my_pending_reviewer'; /** A position the negative-control user holds — never the routed one. */ export const OTHER_POSITION = 'my_pending_bystander'; -/** The named co-approver beside the position: no account is ever this address. */ +/** + * The named co-approver beside the position: an account the pin signs up and + * no one ever signs in as. It must BE an account — a `user` approver no account + * carries adds no slot — or the slate would name no one and the request would + * open on the organization's administrators instead. + */ export const CO_APPROVER = 'my-pending-co-approver@example.com'; export const MyPendingRequest = ObjectSchema.create({ diff --git a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts index cf98d41be15..cddcbbd7db2 100644 --- a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts @@ -36,7 +36,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; -/** A named approver no account in this fixture ever is — the stranded slate. */ +/** + * A named approver who is not there to act — the stranded slate. The pin signs + * the account up and no one ever signs in as it: a `user` approver no account + * carries adds no slot, so an address with no account would leave the slate + * naming no one and open it on the organization's administrators instead. + */ export const DEPARTED_APPROVER = 'override-composite-departed@example.com'; export const OverrideCompositeRequest = ObjectSchema.create({ diff --git a/packages/qa/dogfood/test/fixtures/position-address-readers-fixture.ts b/packages/qa/dogfood/test/fixtures/position-address-readers-fixture.ts index 425ca06259f..baef8cd37b4 100644 --- a/packages/qa/dogfood/test/fixtures/position-address-readers-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/position-address-readers-fixture.ts @@ -33,7 +33,12 @@ export const OTHER_POSITION = 'pa_bystander'; /** The email the email-routed request's `user` approver is authored as. */ export const EMAIL_APPROVER = 'pa-email-reviewer@example.com'; -/** The named co-approver beside the position: no account is ever this address. */ +/** + * The named co-approver beside the position: an account the pin signs up and + * no one ever signs in as. It must BE an account — a `user` approver no account + * carries adds no slot — or the slate would name no one and the request would + * open on the organization's administrators instead. + */ export const CO_APPROVER = 'pa-co-approver@example.com'; const requestObject = (name: string, label: string) => ObjectSchema.create({ diff --git a/packages/qa/dogfood/test/my-pending-position-address.dogfood.test.ts b/packages/qa/dogfood/test/my-pending-position-address.dogfood.test.ts index e3bddddee2e..5f90d88674e 100644 --- a/packages/qa/dogfood/test/my-pending-position-address.dogfood.test.ts +++ b/packages/qa/dogfood/test/my-pending-position-address.dogfood.test.ts @@ -69,6 +69,7 @@ describe('"My Pending" lists a position-routed request under position: (#21350; const submitterToken = await stack.signUp(SUBMITTER); const reviewerToken = await stack.signUp(REVIEWER); const bystanderToken = await stack.signUp(BYSTANDER); + await stack.signUp(CO_APPROVER); const submitterId = await idOf(SUBMITTER); const reviewerId = await idOf(REVIEWER); const bystanderId = await idOf(BYSTANDER); diff --git a/packages/qa/dogfood/test/position-address-readers.dogfood.test.ts b/packages/qa/dogfood/test/position-address-readers.dogfood.test.ts index 936072949cc..f417bb88a7c 100644 --- a/packages/qa/dogfood/test/position-address-readers.dogfood.test.ts +++ b/packages/qa/dogfood/test/position-address-readers.dogfood.test.ts @@ -76,6 +76,7 @@ describe('every slot reader takes the caller\'s acting addresses (#21379)', () = const holderToken = await stack.signUp(HOLDER); const bystanderToken = await stack.signUp(BYSTANDER); const emailToken = await stack.signUp(EMAIL_APPROVER); + await stack.signUp(CO_APPROVER); const adminToken = await stack.signIn(); const holderId = await idOf(HOLDER); const bystanderId = await idOf(BYSTANDER); From 4504d00daaff399a21ba3ed192665fd02a6f549b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:49:28 +0000 Subject: [PATCH 4/6] test(plugin-approvals): the cross-org fixture's directory carries the user it routes to Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../plugin-approvals/src/approver-cross-org.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts b/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts index 1eca20c7a42..6514b994be8 100644 --- a/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approver-cross-org.integration.test.ts @@ -59,6 +59,8 @@ function makeEngine(seed: Record = {}) { /** Plant A sits under the group; the CFO's position lives in the group org. */ const SEED = () => ({ + // A `user` approver holds a slot only when an account carries it (#27397). + sys_user: [{ id: 'u_cfo' }, { id: 'u_plant_mgr' }], sys_organization: [ { id: 'o_group', slug: 'acme-group', parent_organization_id: null }, { id: 'o_plant', slug: 'acme-plant-a', parent_organization_id: 'o_group' }, From 53d1aa445aaf3c2daf5f364b35c62aeb838273ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:14:29 +0000 Subject: [PATCH 5/6] test(plugin-approvals): the new user-approver pins type-check, and the test-layer ledger shrinks by two Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../plugins/plugin-approvals/src/approval-service.test.ts | 6 +++--- packages/plugins/plugin-approvals/test-typecheck-debt.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 78f4d09a659..f518eed7844 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -3962,7 +3962,7 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38 it('`user`: an id no account carries warns as the graph types do, and adds no slot', async () => { const engine = makeFakeEngine(); const { svc, warnings } = svcWithWarnings(engine); - const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX); + const req = await svc.openNodeRequest(approverInput('user', 'u_unknown') as any, CTX) as any; expect(req.pending_approvers).not.toContain('u_unknown'); expect(req.pending_approvers).not.toContain('user:u_unknown'); @@ -3974,7 +3974,7 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38 it('stays quiet for a `user` an account carries', async () => { const engine = makeFakeEngine(); const { svc, warnings } = svcWithWarnings(engine); - const req = await svc.openNodeRequest(approverInput('user', 'u9'), CTX); + const req = await svc.openNodeRequest(approverInput('user', 'u9') as any, CTX) as any; expect(req.pending_approvers).toEqual(['u9']); expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]); @@ -3988,7 +3988,7 @@ describe('ApprovalService — a graph approver that expands to nobody warns (#38 return find(object, options); }; const { svc, warnings } = svcWithWarnings(engine); - const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX); + const req = await svc.openNodeRequest(approverInput('user', 'u_unknown') as any, CTX) as any; expect(req.pending_approvers).toEqual(['u_unknown']); expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]); diff --git a/packages/plugins/plugin-approvals/test-typecheck-debt.json b/packages/plugins/plugin-approvals/test-typecheck-debt.json index 27e52f9a927..527b851cf5f 100644 --- a/packages/plugins/plugin-approvals/test-typecheck-debt.json +++ b/packages/plugins/plugin-approvals/test-typecheck-debt.json @@ -27,14 +27,14 @@ "TS2339: Property 'flow_run_id' does not exist on type '…'.": 1, "TS2339: Property 'id' does not exist on type '…'.": 182, "TS2339: Property 'lock_record' does not exist on type '…'.": 3, - "TS2339: Property 'pending_approvers' does not exist on type '…'.": 39, + "TS2339: Property 'pending_approvers' does not exist on type '…'.": 38, "TS2339: Property 'process_label' does not exist on type '…'.": 2, "TS2339: Property 'process_name' does not exist on type '…'.": 1, "TS2339: Property 'sla_due_at' does not exist on type '…'.": 2, "TS2339: Property 'status' does not exist on type '…'.": 1, "TS2339: Property 'step_label' does not exist on type '…'.": 2, "TS2339: Property 'submitted_at' does not exist on type '…'.": 2, - "TS2345: Argument of type '…' is not assignable to parameter of type '…'.": 16 + "TS2345: Argument of type '…' is not assignable to parameter of type '…'.": 15 }, "src/approver-cross-org.integration.test.ts": { "TS2339: Property 'pending_approvers' does not exist on type '…'.": 5 From 10a61cc4b370976aad5fce482d7873eeb8035a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:27:16 +0000 Subject: [PATCH 6/6] docs(approvals): a user entry no account carries adds no slot Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- content/docs/automation/approvals.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 79d3f0910ce..85c63d6d583 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -320,8 +320,8 @@ node parses its config on entry — **not** at authoring time, and `os lint` has no pairing arm for this one. The refusal is loud, names both keys, and lands before the node opens any approval request. -Prefer a fallback target that cannot itself resolve empty (a literal `user`, or -the `owner` membership tier). If it does resolve empty the node degrades to +Prefer a fallback target that cannot itself resolve empty (a `user` naming a +real account, or the `owner` membership tier). If it does resolve empty the node degrades to `admin_rescue` — the request opens on the organization's administrators and the run is never killed — and the log says both that the fallback fired and that it found nobody. @@ -422,8 +422,10 @@ organization's directory resolves them — see What an entry that resolves to nobody leaves on the slate depends on its kind: - **A person entry** (`manager`, `field`) adds no slot. Nor does a `queue` - with no members, or an `expression` that yields no ids. A `user` entry never - resolves to nobody: its value is the slot. + with no members, or an `expression` that yields no ids. A `user` entry's + value is the slot when an account carries that id or email; one that no + account carries adds no slot, and logs the same `expanded to nobody` warning + as a group entry. - **A group entry** (`position`, `team`, `department`, `org_membership_level`) keeps its `:` literal as a slot, and so does an `expression` value that `resolveAs` re-expands into a group with nobody in it.