Skip to content

Commit 1007134

Browse files
os-salesclaude
andauthored
fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) (#14571)
* fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request `ApprovalService.recall`'s override short-circuit sat above ADR-0044's widened state check with no status test of its own, so a platform or tenant admin could recall a `returned` request — a reach no UI ever offered (`viewer.can_override` is ANDed with `status === 'pending'` where it is computed) and no pin ever held, while the `isOverrideActor` doc block and the viewer flag both said pending-only. Re-scope the short-circuit to `pending`, spelled exactly as the viewer flag, so the gate, the doc block and the flag agree at one point. On `returned` an override actor is judged as any other non-submitter and receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the developer log line now names the status it refused on. The submitter's revise-window recall and the override's pending-request rescue are untouched. Pins in approval-revise.test.ts: the narrowing (both override postures, nothing moves — status, actions, lock, run), the reverse checks on `pending`, the submitter's ADR-0044 recall, and a control that the refusal shape is unchanged. Changeset declares the narrowing as an explicit behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): re-anchor the system-context census after the recall gate edit Pure line rot: the recall-gate hunk in approval-service.ts moved the six `context.isSystem` read sites below it by +20 lines. Rewritten by the gate's own `--fix` (`pnpm gen:system-context-census`); no row content changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * fix(approvals): keep the tracker id out of the recall refusal's runtime log string `check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new internal issue id inside sibling-package string prose; the anchor lives in the adjacent comment, where the reader who can resolve it reads it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): regenerate the system-context census on the merged tree Regeneration commit after merging origin/main (the merge driver deferred content/docs/permissions/system-context.mdx). Both sides had re-anchored row 42 for approval-service.ts line shifts — this branch's recall-gate hunk and #14542's JSDoc hunk — so the anchors are re-derived from the merged code by the gate's own --fix; no row content changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5563bfb commit 1007134

4 files changed

Lines changed: 218 additions & 8 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): the #3424 override reaches a `pending` request only — override-recall of a `returned` request is now refused (#12775)
6+
7+
**Behaviour change, declared explicitly (maintainer ruling 2026-09-02) so that
8+
one revert of this changeset's PR restores the previous behaviour.**
9+
10+
`ApprovalService.recall` admits two callers: the submitter, and a platform or
11+
tenant admin releasing a stuck request (the #3424 privileged override). Recall
12+
is also valid on the LATEST `returned` request of a run — the ADR-0044 revise
13+
window, where the submitter abandons the revision instead of resubmitting.
14+
Those two rules met above the state check: the override short-circuit carried
15+
no status test of its own, so an override actor could recall a `returned`
16+
request too. Nothing else on the platform said so — `isOverrideActor`'s doc
17+
block names a PENDING request, `attachViewers` computes
18+
`viewer.can_override` as `status === 'pending' && isOverrideActor(...)`, and
19+
the `approval_recall` action's override arm reads that flag — so the reach was
20+
API-only, never offered by any UI, and pinned by nothing.
21+
22+
What changes:
23+
24+
- **Override-recall of a `returned` request is refused.** The override
25+
short-circuit in `recall` now applies only while the request is `pending`,
26+
spelled exactly as the viewer flag is computed. On `returned` an override
27+
actor is judged exactly as any other non-submitter and receives the existing
28+
refusal: `403 FORBIDDEN` over REST, with the operation catalog's
29+
`approval_recall_not_submitter` sentence. No new error code, no new envelope.
30+
- **Only `pending` requests are override-recallable** — the same scope as the
31+
other three override levers (approve / reject / reassign are pending-only at
32+
their endpoints), and the same scope the viewer flag has always declared.
33+
34+
What does not change:
35+
36+
- The submitter's own recall of a `returned` request (the ADR-0044 revise
37+
window) is untouched; so is the submitter's recall of a `pending` one.
38+
- The override actor's recall of a `pending` request — the #3424 rescue of a
39+
request routed to an unstaffed position — is untouched, lock release and all.
40+
- The refusal's message, wire code and developer log line keep their shape; the
41+
log line now also names the request status it refused on.
42+
43+
Why: the gate now agrees with the viewer flag and the documented contract at
44+
one point instead of disagreeing with both. If a real operator workflow
45+
depended on override-recalling a `returned` request, this is the change to
46+
revert; the `returned` record lock is already released, so the stuck-record
47+
rescue motive that justifies the override does not apply on that status.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
145145
|:--|:---|:---|:---|:---|
146146
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
147147
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
148-
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3176`, `:3322`, `:3489`, `:3560`, `:3749`, `:3789` |
148+
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3196`, `:3342`, `:3509`, `:3580`, `:3769`, `:3809` |
149149
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
150150
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
151151
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |

packages/plugins/plugin-approvals/src/approval-revise.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import { describe, it, expect, beforeEach } from 'vitest';
1717
import { AutomationEngine, registerScreenNodes } from '@objectstack/service-automation';
1818
import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation';
19+
import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system';
1920
import { ApprovalService } from './approval-service.js';
2021
import { registerApprovalNode } from './approval-node.js';
2122
import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js';
@@ -370,6 +371,148 @@ describe('Send back for revision (ADR-0044)', () => {
370371
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
371372
});
372373

374+
describe('the #3424 override reaches `pending` only — override-recall of a `returned` request is refused (#12775)', () => {
375+
// Maintainer ruling 2026-09-02 (via the director seat): the status-blind
376+
// override short-circuit in `recall` is re-scoped to `pending`, spelled
377+
// exactly as `attachViewers` computes `viewer.can_override`, so the gate,
378+
// the `isOverrideActor` doc block and the viewer flag agree at one point.
379+
// Before, the short-circuit sat above ADR-0044's widened state check and
380+
// admitted an override actor on `returned` too — a reach no UI ever
381+
// offered (the flag is ANDed with `status === 'pending'` where it is
382+
// computed) and no pin ever held. The narrowing is deliberate and
383+
// declared as a behaviour change in the package changeset; this block is
384+
// the pin on it, with its reverse checks.
385+
//
386+
// Both #3424 override postures are exercised. This harness's requests
387+
// carry no organization: a tenant admin qualifies as an override actor on
388+
// them (`isOverrideActor`: a null-org request is global), and — as the
389+
// `can_override` pin above notes — `loadRequest` narrows by the CALLER's
390+
// org, so neither context carries a `tenantId` (the ADR-0095 `posture` is
391+
// the tenant-admin signal here). Neither is the submitter, who is
392+
// `submitter`.
393+
const PLATFORM_ADMIN = { isSystem: false, userId: 'root', positions: [], permissions: ['admin_full_access'] } as any;
394+
const TENANT_ADMIN = { isSystem: false, userId: 'org_owner', posture: 'TENANT_ADMIN', positions: [], permissions: [] } as any;
395+
/**
396+
* `@objectstack/rest`'s `handleApprovalError` derives 403 + the ADR-0112
397+
* wire code `FORBIDDEN` from this prefix and strips it off the body — the
398+
* prefix IS the status/code contract at the service seam.
399+
*/
400+
const WIRE_CODE = /^FORBIDDEN:\s/;
401+
const NOT_SUBMITTER = `FORBIDDEN: ${BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter}`;
402+
const refusalOf = (p: Promise<unknown>) => p.then(() => null, (e: any) => e);
403+
404+
/** A pending request on `x1`, with the REAL record-lock hook bound. */
405+
async function pendingRequest() {
406+
registerReviseFlow();
407+
const { runId, req } = await startFlow();
408+
let hook: ((ctx: any) => Promise<void>) | undefined;
409+
bindApprovalLockHook({
410+
registerHook: (_e: string, h: any) => { hook = h; },
411+
unregisterHooksByPackage: () => 0,
412+
find: fake.find.bind(fake),
413+
} as any, noopLogger);
414+
expect(hook).toBeDefined();
415+
const editAttempt = () => hook!({
416+
object: 'fin_expense',
417+
input: { id: 'x1', data: { amount: 1200 } },
418+
session: { isSystem: false, positions: [] },
419+
});
420+
return { runId, req, editAttempt };
421+
}
422+
423+
/** The same request, sent back by its approver: `returned`, unlocked (ADR-0044). */
424+
async function returnedRequest() {
425+
const opened = await pendingRequest();
426+
await service.sendBack(opened.req.id, { actorId: 'u1' }, asUser('u1'));
427+
const row = await service.getRequest(opened.req.id, SYSTEM_CTX);
428+
expect(row!.status).toBe('returned');
429+
return opened;
430+
}
431+
432+
const runStatus = async () => (await automation.listRuns('expense_approval'))[0].status;
433+
434+
it('the narrowing: an override actor is refused on `returned` with the non-submitter refusal, and nothing moves', async () => {
435+
const { req, editAttempt } = await returnedRequest();
436+
await expect(editAttempt()).resolves.toBeUndefined(); // returned → unlocked (ADR-0044)
437+
const runBefore = await runStatus();
438+
439+
for (const [label, actor, actorId] of [['platform admin', PLATFORM_ADMIN, 'root'], ['tenant admin', TENANT_ADMIN, 'org_owner']] as const) {
440+
// The viewer flag the gate now agrees with, read by the same actor.
441+
const seen = await service.getRequest(req.id, actor);
442+
expect(seen!.status, label).toBe('returned');
443+
expect(seen!.viewer!.can_override, label).toBe(false);
444+
445+
const err = await refusalOf(service.recall(req.id, { actorId, comment: 'rescue' }, actor));
446+
expect(err, label).toBeInstanceOf(Error);
447+
// The EXISTING non-submitter refusal, envelope and sentence: the
448+
// `FORBIDDEN:` prefix is what REST maps to 403 + code `FORBIDDEN`;
449+
// the sentence is the operation catalog's (#11993). No new code.
450+
expect(err.message, label).toMatch(WIRE_CODE);
451+
expect(err.message, label).toBe(NOT_SUBMITTER);
452+
}
453+
454+
// Nothing moved: status, the action ledger, the record lock, the run.
455+
const after = await service.getRequest(req.id, SYSTEM_CTX);
456+
expect(after!.status).toBe('returned');
457+
expect(await actionsOf(req.id)).not.toContain('recall');
458+
await expect(editAttempt()).resolves.toBeUndefined(); // still unlocked — the refusal touched no lock
459+
expect(await runStatus()).toBe(runBefore);
460+
expect(await runStatus()).not.toBe('cancelled');
461+
// The window is still open for its owner.
462+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
463+
const round2 = await pendingReq();
464+
expect(round2.id).not.toBe(req.id);
465+
expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
466+
});
467+
468+
it('reverse check: the same override actor still recalls the same request while `pending`, and the flag says so', async () => {
469+
const { req, editAttempt } = await pendingRequest();
470+
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked
471+
const seen = await service.getRequest(req.id, PLATFORM_ADMIN);
472+
expect(seen!.status).toBe('pending');
473+
expect(seen!.viewer!.can_override).toBe(true);
474+
475+
const out = await service.recall(req.id, { actorId: 'root', comment: 'unstaffed role' }, PLATFORM_ADMIN);
476+
expect(out.request.status).toBe('recalled');
477+
expect(out.resumed).toBe(true); // resumed down the reject branch
478+
expect(await actionsOf(req.id)).toContain('recall');
479+
await expect(editAttempt()).resolves.toBeUndefined(); // the #3424 release still happens
480+
});
481+
482+
it('reverse check (tenant admin): admitted on `pending` too — the narrowing is about status, not posture', async () => {
483+
const { req } = await pendingRequest();
484+
const out = await service.recall(req.id, { actorId: 'org_owner' }, TENANT_ADMIN);
485+
expect(out.request.status).toBe('recalled');
486+
expect(await actionsOf(req.id)).toContain('recall');
487+
});
488+
489+
it("the submitter's own revise-window recall is untouched (ADR-0044)", async () => {
490+
const { runId, req } = await returnedRequest();
491+
const out = await service.recall(req.id, { actorId: 'submitter', comment: 'never mind' }, asUser('submitter'));
492+
expect(out.request.status).toBe('recalled');
493+
expect(out.resumed).toBe(false); // terminally cancelled, not resumed
494+
expect(out.runId).toBe(runId);
495+
expect(await runStatus()).toBe('cancelled');
496+
expect(await actionsOf(req.id)).toContain('recall');
497+
});
498+
499+
it('control: a non-submitter without the override is refused on `returned` exactly as before — same envelope, same sentence', async () => {
500+
const { req } = await returnedRequest();
501+
// The approver who sent it back: not the submitter, holds no override.
502+
const plain = await refusalOf(service.recall(req.id, { actorId: 'u1' }, asUser('u1')));
503+
expect(plain).toBeInstanceOf(Error);
504+
expect(plain.message).toMatch(WIRE_CODE);
505+
expect(plain.message).toBe(NOT_SUBMITTER);
506+
// The override actor's refusal on `returned` is byte-identical to it:
507+
// the narrowing added no refusal shape, it routed one more caller
508+
// through the existing one.
509+
const admin = await refusalOf(service.recall(req.id, { actorId: 'root' }, PLATFORM_ADMIN));
510+
expect(admin.message).toBe(plain.message);
511+
expect((await service.getRequest(req.id, SYSTEM_CTX))!.status).toBe('returned');
512+
expect(await actionsOf(req.id)).not.toContain('recall');
513+
});
514+
});
515+
373516
it('refuses resubmit while another pending request collides on the record (run stays resumable)', async () => {
374517
registerReviseFlow();
375518
const { runId, req } = await startFlow();

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2889,6 +2889,13 @@ export class ApprovalService implements IApprovalService {
28892889
* is then paused at the revise-window node (no reject edge), so it is
28902890
* terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
28912891
* than resumed.
2892+
*
2893+
* The #3424 privileged override reaches a PENDING request only (#12775,
2894+
* maintainer ruling 2026-09-02). On `returned` an override actor is refused
2895+
* exactly as any other non-submitter: the gate is spelled as `attachViewers`
2896+
* computes `viewer.can_override`, so the gate, the
2897+
* {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
2898+
* at one point.
28922899
*/
28932900
async recall(
28942901
requestId: string,
@@ -2907,21 +2914,34 @@ export class ApprovalService implements IApprovalService {
29072914
if (raw.status !== 'pending' && !inReviseWindow) {
29082915
throw new Error(`INVALID_STATE: request is ${raw.status}`);
29092916
}
2910-
// The submitter withdraws their own request; a privileged admin may recall
2911-
// any pending request to release a stuck record (#3424).
2917+
// The submitter withdraws their own request — while it is `pending`, or
2918+
// while it is `returned` (ADR-0044: abandoning the revision instead of
2919+
// resubmitting). A privileged admin may recall a PENDING request to
2920+
// release a stuck record (#3424) — and only a pending one. This
2921+
// short-circuit used to sit above ADR-0044's widened state check with no
2922+
// status test of its own, so it also admitted the override caller on
2923+
// `returned`: a reach no UI ever offered (`viewer.can_override` is ANDed
2924+
// with `status === 'pending'` where it is computed, in `attachViewers`)
2925+
// and no pin ever held. #12775 (maintainer ruling 2026-09-02) re-scoped it
2926+
// to `pending`, spelled exactly as the viewer flag, so the gate, the
2927+
// `isOverrideActor` doc block and the flag agree at one point. On
2928+
// `returned` the override caller is judged exactly as any other
2929+
// non-submitter: the branch below, its catalog sentence, its `FORBIDDEN`
2930+
// wire code.
29122931
//
2913-
// [#11993] The GATE is untouched — who may recall an approval is exactly
2914-
// what it was. Only the refusal's user-facing half changed: it used to be
2932+
// [#11993] Only the refusal's user-facing half changed there: it used to be
29152933
// one hardcoded English sentence that Console rendered verbatim in a
29162934
// toast. See {@link ApprovalService.userFacingRefusal}.
2917-
if (!this.isOverrideActor(context, raw.organization_id ?? null)
2918-
&& raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2935+
const overrideAdmits = raw.status === 'pending'
2936+
&& this.isOverrideActor(context, raw.organization_id ?? null);
2937+
if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
29192938
// The developer's half: the ids the catalog sentence deliberately does
29202939
// not name (the throw site knows the submitter only as an opaque user
29212940
// id), kept where a developer reads them and a user never does.
29222941
const developerMessage =
29232942
`[approvals] recall refused: actor '${actorId}' is not the submitter of request `
2924-
+ `'${requestId}' (submitter '${String(raw.submitter_id)}') and holds no #3424 override`;
2943+
+ `'${requestId}' (submitter '${String(raw.submitter_id)}') and holds no #3424 override `
2944+
+ `for a '${raw.status}' request (the override reaches pending requests only)`;
29252945
this.logger?.warn?.(developerMessage, {
29262946
request: requestId,
29272947
actor: actorId,

0 commit comments

Comments
 (0)