Skip to content

Commit d7a204a

Browse files
committed
fix(plugin-approvals): an SLA reassign to a target that resolves to no one keeps the slate
An approval node's `escalation: { action: 'reassign', escalateTo }` falls back to the literal `escalateTo` when its position expansion is empty. A literal that resolved to no one (an unstaffed or mistyped position, a user id with no account, a `team:` literal) was written onto the slate on timeout, stranding the request the way the reassign door did before #25592. The fallback is now judged by the door's own predicate, split out as `reassignTargetRefusal` (the door's assert is a thin throw over it). A target that does not resolve leaves the slate, labels and approver index as they were, takes the `notify` branch to the current slate, and the `escalate` audit row records why. The submitter notice names the action actually taken. A failed `sys_user` read throws before the audit row, so the next sweep retries. Claude-Session: https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp Co-authored-by: Claude <noreply@anthropic.com>
1 parent a57643d commit d7a204a

4 files changed

Lines changed: 392 additions & 23 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): an SLA escalation that reassigns to a target resolving to no one keeps the request on its approvers and notifies them, instead of stranding it on a slate nobody can decide
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An approval node's `escalation: { action: 'reassign', escalateTo }` expands `escalateTo` to the holders of that position, and when nobody holds it falls back to the value itself, so a config naming a specific user id keeps working. That fallback is now judged by the same rule the reassign door applies: a user id or email an account carries (and not a user of other organizations only), or `position:<name>` for a position someone in the request's organization holds.
11+
12+
- **A target that resolves** reassigns exactly as before.
13+
- **A target that does not** (an unstaffed or mistyped position name, a user id with no account, a `team:` / `department:` / `org_membership_level:` / `role:` literal) no longer replaces the slate. The pending approvers are kept and receive `approval.sla_breached`, and the `escalate` audit row records `reassign → <target> — not applied: <why>`. The escalation still fires once.
14+
- **The submitter's escalation notice** now names the action actually taken (`notify` when the reassign was not applied, or when no `escalateTo` was set), not the configured one.
15+
- A directory read that fails during the sweep leaves the request untouched and unmarked, so the next sweep retries it.
16+
17+
Nothing an author writes is refused that was accepted before. To have an overdue request move, name a staffed position or a person in the request's organization in `escalateTo`.

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

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1945,7 +1945,7 @@ describe('ApprovalService (node era)', () => {
19451945
const req = await svc.openNodeRequest(
19461946
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
19471947
);
1948-
staffDirectory(engine, 'u7');
1948+
staffDirectory(engine, 'u7', 'boss');
19491949
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, SYS);
19501950
expect(indexRows().map(r => r.approver).sort()).toEqual(['u2', 'u7']);
19511951

@@ -2084,6 +2084,7 @@ describe('ApprovalService (node era)', () => {
20842084
});
20852085

20862086
it('runEscalations: reassign replaces the approver set with escalateTo', async () => {
2087+
staffDirectory(engine, 'boss');
20872088
const req = await svc.openNodeRequest(
20882089
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
20892090
);
@@ -2132,6 +2133,137 @@ describe('ApprovalService (node era)', () => {
21322133
expect(emitted[0].audience).toEqual(['u9', 'u5']);
21332134
});
21342135

2136+
// ── SLA reassign target resolution (#25598) ─────────────────────
2137+
//
2138+
// An escalation `reassign` whose `escalateTo` expands to no position holder
2139+
// falls back to the literal. A literal that resolves to no one used to be
2140+
// written onto the slate, stranding the request the way the reassign door
2141+
// did (#25485). Now the door's predicate judges it: the slate stays, its
2142+
// approvers are told, and the audit row says why.
2143+
describe('SLA reassign target resolution (#25598)', () => {
2144+
let emitted: any[];
2145+
2146+
beforeEach(() => {
2147+
emitted = [];
2148+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
2149+
});
2150+
2151+
/** Open an overdue `reassign` escalation to `escalateTo` on `slate`, the opening's notices cleared. */
2152+
async function overdueReassign(escalateTo: string, recordId = 'opp1'): Promise<string> {
2153+
const { id } = (await svc.openNodeRequest(
2154+
openInput(['u9', 'u2'], { recordId, record: { id: recordId } }, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo, notifySubmitter: true } }), CTX,
2155+
)) as { id: string };
2156+
expect(engine._tables['sys_approval_request'].find(r => r.id === id)?.organization_id).toBe('t1');
2157+
makeOverdue(id);
2158+
emitted.splice(0);
2159+
return id;
2160+
}
2161+
2162+
const requestRow = (id: string) => engine._tables['sys_approval_request'].find(r => r.id === id)!;
2163+
const indexedOf = (id: string) => (engine._tables['sys_approval_approver'] ?? [])
2164+
.filter(r => r.request_id === id).map(r => r.approver).sort();
2165+
const escalateRow = (id: string) => (engine._tables['sys_approval_action'] ?? [])
2166+
.find(r => r.request_id === id && r.action === 'escalate');
2167+
2168+
async function expectKeptAndNotified(id: string, escalateTo: string, why: string) {
2169+
const before = { ...requestRow(id) };
2170+
emitted.splice(0);
2171+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
2172+
const row = requestRow(id);
2173+
expect(row.status).toBe('pending');
2174+
expect(row.pending_approvers).toBe('u9,u2');
2175+
expect(row.pending_approver_labels).toEqual(before.pending_approver_labels);
2176+
expect(indexedOf(id)).toEqual(['u2', 'u9']);
2177+
expect(escalateRow(id)?.comment).toBe(
2178+
`reassign → ${escalateTo} — not applied: ${why} The pending approvers were kept and notified.`,
2179+
);
2180+
// The current slate hears the lapse; the target that resolves to no one
2181+
// is not in the audience, and nothing announces a move.
2182+
expect(emitted.map(e => e.topic)).toEqual(['approval.sla_breached', 'approval.sla_breached']);
2183+
expect(emitted[0].audience).toEqual(['u9', 'u2']);
2184+
expect(emitted.some(e => e.topic === 'approval.escalated')).toBe(false);
2185+
// The submitter is told the action that was TAKEN.
2186+
expect(emitted[1].audience).toEqual(['u1']);
2187+
expect(emitted[1].payload.body).toContain("escalation action 'notify' was taken");
2188+
// Still single-shot: the escalate row is the marker.
2189+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
2190+
}
2191+
2192+
it('keeps the slate when escalateTo names no account (a departed user, a mistyped id)', async () => {
2193+
const id = await overdueReassign('ghost_user');
2194+
await expectKeptAndNotified(id, 'ghost_user', 'it names no user in this request\'s organization.');
2195+
});
2196+
2197+
it('keeps the slate when escalateTo is a position name nobody holds here', async () => {
2198+
engine._tables['sys_user_position'] = [
2199+
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't2' }, // other tenant only
2200+
];
2201+
const id = await overdueReassign('approvals_supervisor');
2202+
await expectKeptAndNotified(id, 'approvals_supervisor', 'it names no user in this request\'s organization.');
2203+
});
2204+
2205+
it('keeps the slate when escalateTo is a user whose only membership is another organization', async () => {
2206+
engine._tables['sys_user'] = [{ id: 'u_far' }];
2207+
engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u_far', organization_id: 't2' }];
2208+
const id = await overdueReassign('u_far');
2209+
await expectKeptAndNotified(id, 'u_far', 'it names no user in this request\'s organization.');
2210+
});
2211+
2212+
it('keeps the slate when escalateTo is a slot literal no approver acts under, or a position:<p> nobody holds', async () => {
2213+
const team = await overdueReassign('team:night_shift');
2214+
await expectKeptAndNotified(team, 'team:night_shift', 'it is a slot literal no approver acts under, so no one could ever decide the request.');
2215+
const ghost = await overdueReassign('position:ghost', 'opp2');
2216+
await expectKeptAndNotified(ghost, 'position:ghost', 'no one holds position \'ghost\' in this request\'s organization.');
2217+
});
2218+
2219+
it('a failed sys_user read moves nothing and records nothing, so the next sweep escalates', async () => {
2220+
staffDirectory(engine, 'boss');
2221+
const id = await overdueReassign('boss');
2222+
const find = engine.find.bind(engine);
2223+
(engine as any).find = async (object: string, q: any) => {
2224+
if (object === 'sys_user') throw new Error('directory down');
2225+
return find(object, q);
2226+
};
2227+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
2228+
expect(requestRow(id).pending_approvers).toBe('u9,u2');
2229+
expect(escalateRow(id)).toBeUndefined();
2230+
expect(emitted).toEqual([]);
2231+
(engine as any).find = find;
2232+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
2233+
expect(requestRow(id).pending_approvers).toBe('boss');
2234+
});
2235+
2236+
it('still reassigns to an account\'s user id, its email, and a position:<p> someone holds here', async () => {
2237+
engine._tables['sys_user'] = [{ id: 'boss', email: 'boss@example.com' }];
2238+
engine._tables['sys_member'] = [{ id: 'm1', user_id: 'boss', organization_id: 't1' }];
2239+
engine._tables['sys_user_position'] = [
2240+
{ id: 'up1', user_id: 'u5', position: 'finance', organization_id: 't1' },
2241+
];
2242+
const byId = await overdueReassign('boss');
2243+
const byEmail = await overdueReassign('boss@example.com', 'opp2');
2244+
const byPosition = await overdueReassign('position:finance', 'opp3');
2245+
expect(await svc.runEscalations()).toMatchObject({ escalated: 3 });
2246+
expect(requestRow(byId).pending_approvers).toBe('boss');
2247+
expect(requestRow(byEmail).pending_approvers).toBe('boss@example.com');
2248+
expect(requestRow(byPosition).pending_approvers).toBe('position:finance');
2249+
expect(indexedOf(byPosition)).toEqual(['position:finance']);
2250+
for (const [id, to] of [[byId, 'boss'], [byEmail, 'boss@example.com'], [byPosition, 'position:finance']]) {
2251+
expect(escalateRow(id)?.comment).toBe(`reassign → ${to}`);
2252+
}
2253+
// A `type:value` slot is no messaging address (`notify` drops it), so the
2254+
// position's hand-off announces itself to no one; that is today's notice.
2255+
expect(emitted.filter(e => e.topic === 'approval.escalated').map(e => e.audience))
2256+
.toEqual([['boss'], ['boss@example.com']]);
2257+
const submitterNotices = emitted.filter(e => e.topic === 'approval.sla_breached');
2258+
expect(submitterNotices).toHaveLength(3);
2259+
expect(submitterNotices.every(e => e.payload.body.includes("escalation action 'reassign' was taken"))).toBe(true);
2260+
// …and the holder of the position then decides with the default actor.
2261+
await svc.decideNode(byPosition, { decision: 'approve', actorId: 'u5' },
2262+
{ userId: 'u5', tenantId: 't1', positions: ['finance'], permissions: [] } as any);
2263+
expect(requestRow(byPosition).status).toBe('approved');
2264+
});
2265+
});
2266+
21352267
it('runEscalations: skips requests that are not yet due or have no SLA', async () => {
21362268
await svc.openNodeRequest(
21372269
openInput(['u9'], {}, { escalation: { timeoutHours: 1000, action: 'auto_approve' } }), CTX,

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

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5604,38 +5604,47 @@ export class ApprovalService implements IApprovalService {
56045604
* screens.
56055605
*/
56065606
private async assertReassignTargetResolves(to: string, organizationId: string | null): Promise<void> {
5607-
const refuse = (why: string): never => {
5608-
throw new Error(
5609-
`VALIDATION_FAILED: cannot reassign to '${to}' — ${why} The request and its approvers are unchanged. `
5610-
+ `Reassign to the user id or email of a person in this request's organization, or to `
5611-
+ `'position:<name>' for a position someone in it holds.`,
5612-
);
5613-
};
5607+
const why = await this.reassignTargetRefusal(to, organizationId);
5608+
if (why === undefined) return;
5609+
throw new Error(
5610+
`VALIDATION_FAILED: cannot reassign to '${to}' — ${why} The request and its approvers are unchanged. `
5611+
+ `Reassign to the user id or email of a person in this request's organization, or to `
5612+
+ `'position:<name>' for a position someone in it holds.`,
5613+
);
5614+
}
5615+
5616+
/**
5617+
* Why `to` resolves to no one in `organizationId` — the sentence a refusal
5618+
* carries — or `undefined` when someone can take a slot under it. The ONE
5619+
* predicate behind {@link assertReassignTargetResolves} (the door) and
5620+
* {@link escalateRequest}'s `reassign` (the SLA sweep, #25598); its contract
5621+
* is that docblock's.
5622+
*/
5623+
private async reassignTargetRefusal(to: string, organizationId: string | null): Promise<string | undefined> {
56145624
const position = addressedPosition(to);
56155625
if (position !== undefined) {
5616-
if ((await this.expandPositionUsers(position, organizationId)).length) return;
5617-
refuse(`no one holds position '${position}' in this request's organization.`);
5626+
if ((await this.expandPositionUsers(position, organizationId)).length) return undefined;
5627+
return `no one holds position '${position}' in this request's organization.`;
56185628
}
56195629
if (to.includes(':')) {
5620-
refuse('it is a slot literal no approver acts under, so no one could ever decide the request.');
5630+
return 'it is a slot literal no approver acts under, so no one could ever decide the request.';
56215631
}
5632+
const noUser = 'it names no user in this request\'s organization.';
56225633
const rows = await this.engine.find('sys_user', {
56235634
where: to.includes('@') ? { email: to } : { id: to },
56245635
fields: ['id'], limit: 1, context: SYSTEM_CTX,
56255636
});
56265637
const userId = Array.isArray(rows) && rows[0]?.id != null ? String(rows[0].id) : '';
5627-
if (!userId) refuse('it names no user in this request\'s organization.');
5628-
if (!organizationId) return;
5638+
if (!userId) return noUser;
5639+
if (!organizationId) return undefined;
56295640
let memberships: any[] = [];
56305641
try {
56315642
memberships = await this.engine.find('sys_member', {
56325643
where: { user_id: userId }, fields: ['organization_id'], limit: 1000, context: SYSTEM_CTX,
56335644
});
5634-
} catch { return; } // membership unreadable ⇒ no tenancy fact, as in the sibling screens
5645+
} catch { return undefined; } // membership unreadable ⇒ no tenancy fact, as in the sibling screens
56355646
const orgs = (memberships ?? []).map((r: any) => String(r?.organization_id ?? '')).filter(Boolean);
5636-
if (orgs.length && !orgs.includes(String(organizationId))) {
5637-
refuse('it names no user in this request\'s organization.');
5638-
}
5647+
return orgs.length && !orgs.includes(String(organizationId)) ? noUser : undefined;
56395648
}
56405649

56415650
/**
@@ -7040,12 +7049,26 @@ export class ApprovalService implements IApprovalService {
70407049
// empty expansion falls back to the literal, so a config naming a
70417050
// specific user id keeps working unchanged.
70427051
let escalatees: string[] = [];
7052+
let literalTarget = false;
70437053
if (escalateTo) {
70447054
try {
70457055
escalatees = await this.expandPositionUsers(escalateTo, raw.organization_id ?? null);
70467056
} catch { escalatees = []; }
7047-
if (!escalatees.length) escalatees = [escalateTo];
7048-
}
7057+
if (!escalatees.length) { escalatees = [escalateTo]; literalTarget = true; }
7058+
}
7059+
// [#25598] …but a literal must still name someone who can take the slot,
7060+
// by the door's own predicate (#25485). One that resolves to no one (an
7061+
// unstaffed position, a mistyped name, a departed user, a `team:` literal)
7062+
// would move the request off its real approvers onto a slate nobody can
7063+
// decide. So the slate stays, its approvers are told the SLA lapsed (the
7064+
// `notify` branch), and the audit row says why. A failed `sys_user` read
7065+
// throws before the audit row, so the next sweep retries the request.
7066+
const unresolved = action === 'reassign' && literalTarget
7067+
? await this.reassignTargetRefusal(escalateTo!, raw.organization_id ?? null)
7068+
: undefined;
7069+
if (unresolved !== undefined) escalatees = [];
7070+
// The action this sweep TAKES: a reassign with no one to hand to notifies.
7071+
const taken = action === 'reassign' && !escalatees.length ? 'notify' : action;
70497072

70507073
// Audit first — this row IS the idempotency marker (ADR-0042 §1), and the
70517074
// marker is its KIND (`escalate`), not its actor. A machine has no
@@ -7057,11 +7080,12 @@ export class ApprovalService implements IApprovalService {
70577080
id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
70587081
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate',
70597082
actor_id: null,
7060-
comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`,
7083+
comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`
7084+
+ (unresolved !== undefined ? ` — not applied: ${unresolved} The pending approvers were kept and notified.` : ''),
70617085
created_at: now,
70627086
}), { context: SYSTEM_CTX });
70637087

7064-
if (action === 'reassign' && escalatees.length) {
7088+
if (taken === 'reassign') {
70657089
await this.engine.update('sys_approval_request', {
70667090
id: raw.id, pending_approvers: escalatees.join(','), updated_at: now,
70677091
pending_approver_labels: await this.slateLabels(escalatees),
@@ -7086,7 +7110,8 @@ export class ApprovalService implements IApprovalService {
70867110
comment: 'SLA escalation',
70877111
}, SYSTEM_CTX, 'escalation');
70887112
} else {
7089-
// 'notify' (and the reassign-without-target fallback)
7113+
// 'notify' (and a reassign with no one to hand to: no target, or one
7114+
// that resolves to no one)
70907115
await this.notify({
70917116
topic: 'approval.sla_breached',
70927117
organizationId: raw.organization_id,
@@ -7106,7 +7131,7 @@ export class ApprovalService implements IApprovalService {
71067131
source: { object: 'sys_approval_request', id: raw.id },
71077132
...approvalNotice(approvalNotificationText({
71087133
title: 'approvalSlaBreachedSubmitterTitle', body: 'approvalSlaBreachedSubmitterBody',
7109-
params: { ...subject, action },
7134+
params: { ...subject, action: taken },
71107135
})),
71117136
});
71127137
}

0 commit comments

Comments
 (0)