Skip to content

Commit afda2d0

Browse files
committed
Merge origin/main into claude/issue-12758-datasource-credentials-ref
Brings the branch onto the current type-check-debt ledger sweep (#12798) and the dispatch-gates hintCovers fix (#12794), so the gate-family derivation below reads a tree someone is actually on rather than a 5-commit-stale one. Part of #12758
2 parents df8c1cd + 96dc446 commit afda2d0

9 files changed

Lines changed: 802 additions & 60 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(plugin-approvals): the `approval_recall` action shows for the #3424 admin override (#12716)
6+
7+
`ApprovalService.recall` has admitted two callers since #3424 — the submitter,
8+
and a platform/tenant admin releasing a stuck request — and `isOverrideActor`'s
9+
own doc block names recall as one of the four override levers in so many words.
10+
The declared action that reaches that endpoint did not agree: `approval_recall`'s
11+
`visible` predicate was submitter-only, while its three siblings
12+
(`approval_approve` / `approval_reject` / `approval_reassign`) each OR in
13+
`record.viewer.can_override`.
14+
15+
So recall was the one lever the override covers whose button never appeared. An
16+
admin rescuing an approval routed to an unstaffed position could approve or
17+
reject their way out — writing a decision nobody made — or reassign it, but
18+
could not simply withdraw it. This is declared-vs-enforced drift in the less
19+
usual direction: a capability the server grants that no UI entry exposed.
20+
21+
`approval_recall`'s `visible` now ORs in `record.viewer.can_override`, spelled
22+
byte-identically to the three siblings.
23+
24+
Not a permission change: the service's authorisation set is untouched, and
25+
`can_override` was already computed server-side for every viewer.
26+
27+
**Pending-only, and enforced rather than asserted.** The new arm carries no
28+
status test of its own — neither do the siblings — because the flag is already
29+
status-scoped where it is computed: `attachViewers` sets
30+
`can_override: row.status === 'pending' && isOverrideActor(...)`, ANDed, so the
31+
flag can never be true off `pending` and the arm is pending-only in effect
32+
however CEL groups the expression. The submitter's own `returned` (revise
33+
window) arm is unchanged. Pinned in both directions, with the flag's own scoping
34+
pinned against the real service on a genuinely `returned` row.

‎packages/plugins/plugin-approvals/src/action-predicate-sparse-face.test.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,54 @@ describe('#8990 — the fail-closed intent survives as a real false, and the lev
121121
expect(evaluate(visibleOf('approval_remind'), approver)).toBe(false);
122122
});
123123

124-
it('an override-only admin still gets the three core decision levers and nothing else (#3424)', () => {
124+
it('an override-only admin gets the four levers the override covers, and nothing else (#3424, +recall #12716)', () => {
125+
// Re-expressed for #12716. This pin previously read "the three core decision
126+
// levers and nothing else" — true when written, and it is the shape of pin
127+
// the recall override arm was expected to turn red. It did not go red (it
128+
// never asserted anything about recall), but its TITLE became false the
129+
// moment recall joined the set, so it is restated rather than left to read
130+
// as a claim the code no longer honours.
125131
const admin = { status: 'pending', viewer: { can_act: false, can_override: true, is_submitter: false } };
126132
expect(evaluate(visibleOf('approval_approve'), admin)).toBe(true);
127133
expect(evaluate(visibleOf('approval_reject'), admin)).toBe(true);
128134
expect(evaluate(visibleOf('approval_reassign'), admin)).toBe(true);
135+
// #12716 — the fourth lever. An override admin is a NON-SUBMITTER who now
136+
// sees Recall: the service has authorised them since #3424, and this is the
137+
// one lever that releases a stuck request without writing a decision on
138+
// someone else's behalf.
139+
expect(evaluate(visibleOf('approval_recall'), admin)).toBe(true);
129140
// `can_override` was never OR'd into the secondary levers, and still is not.
130141
expect(evaluate(visibleOf('approval_send_back'), admin)).toBe(false);
131142
expect(evaluate(visibleOf('approval_request_info'), admin)).toBe(false);
143+
// Nor into the remaining submitter levers — recall is the only one that
144+
// moved, and remind/resubmit stay shut for an actor who is not the submitter.
145+
expect(evaluate(visibleOf('approval_remind'), admin)).toBe(false);
146+
expect(evaluate(visibleOf('approval_resubmit'), { ...admin, status: 'returned' })).toBe(false);
147+
});
148+
149+
it('the recall override arm is pending-only in effect, because `can_override` is itself pending-scoped (#12716)', () => {
150+
// The negative direction, and the reason the arm needs no status test of its
151+
// own. `attachViewers` computes
152+
// can_override: row.status === 'pending' && isOverrideActor(...)
153+
// — ANDed — so for the SAME override actor the flag the service attaches is
154+
// true on `pending` and false on `returned`. Both rows below are viewer
155+
// blocks the service really emits: forcing `can_override: true` onto a
156+
// `returned` row would pin a state the server never produces, and would
157+
// measure CEL's grouping rather than the product's behaviour.
158+
//
159+
// That is what makes "pending-only" enforced rather than asserted in prose.
160+
// The flag's own scoping is pinned against the REAL service — an override
161+
// admin reading a genuinely `returned` request — in `approval-revise.test.ts`;
162+
// this pair is the predicate half of the same claim.
163+
const onPending = { status: 'pending', viewer: { can_act: false, can_override: true, is_submitter: false } };
164+
const onReturned = { status: 'returned', viewer: { can_act: false, can_override: false, is_submitter: false } };
165+
expect(evaluate(visibleOf('approval_recall'), onPending)).toBe(true);
166+
expect(evaluate(visibleOf('approval_recall'), onReturned)).toBe(false);
167+
// And the submitter's own `returned` arm is untouched by the new OR — the
168+
// revise-window withdraw stays exactly as available as it was.
169+
expect(evaluate(visibleOf('approval_recall'), {
170+
status: 'returned', viewer: { can_act: false, can_override: false, is_submitter: true },
171+
})).toBe(true);
132172
});
133173

134174
it('the submitter still gets remind / recall on pending and resubmit / recall on returned', () => {

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,39 @@ describe('Send back for revision (ADR-0044)', () => {
315315
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // round 2 pending → re-locked
316316
});
317317

318+
it('viewer.can_override drops on `returned`, which is what keeps the recall override arm pending-only (#12716)', async () => {
319+
// `sys_approval_request`'s `approval_recall` action ORs in
320+
// `record.viewer.can_override`, spelled exactly like its three siblings and
321+
// with no status test of its own. What makes that arm pending-only is not
322+
// the predicate — it is `attachViewers`, which ANDs the status in when it
323+
// COMPUTES the flag. `action-predicate-sparse-face.test.ts` pins the
324+
// predicate half against fixture viewer blocks; this pins the half those
325+
// fixtures stand for, against a genuinely `returned` row produced by the
326+
// real send-back path. Without it, "pending-only" would be a property of
327+
// hand-written fixtures rather than of the service.
328+
//
329+
// Relaxing the flag is also not available as a fix: it feeds the three
330+
// sibling predicates too, and THEIR endpoints are pending-only
331+
// (`decideNode`, and reassign via `loadPendingRow`), so widening it would
332+
// put those three buttons on statuses their services refuse.
333+
registerReviseFlow();
334+
const { req } = await startFlow();
335+
// A platform admin with no tenant scope: `loadRequest` narrows by the
336+
// CALLER's org, and this harness's requests carry none.
337+
const ADMIN = { isSystem: false, userId: 'root', positions: [], permissions: ['admin_full_access'] } as any;
338+
339+
// Positive control first — the same actor, same request, on `pending`.
340+
const whilePending = await service.getRequest(req.id, ADMIN);
341+
expect(whilePending!.status).toBe('pending');
342+
expect(whilePending!.viewer!.can_override).toBe(true);
343+
344+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
345+
346+
const whileReturned = await service.getRequest(req.id, ADMIN);
347+
expect(whileReturned!.status).toBe('returned');
348+
expect(whileReturned!.viewer!.can_override).toBe(false);
349+
});
350+
318351
it('recall crossing the revise window cancels the run (returned → recalled)', async () => {
319352
registerReviseFlow();
320353
const { runId, req } = await startFlow();

‎packages/plugins/plugin-approvals/src/sys-approval-request.object.test.ts‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
* • every `type:'api'` target resolves `{id}` and points at a route that the
1414
* REST server actually registers (approve/reject/reassign/recall/remind/
1515
* request-info/revise/resubmit) — a typo'd verb would 404 silently in the UI;
16-
* • submitter-only levers (remind/recall/resubmit) gate on
17-
* `submitter_id == ctx.user.id` so a non-submitter never sees them.
16+
* • submitter levers (remind/recall/resubmit) gate on the server-computed
17+
* `record.viewer.is_submitter`, so a plain non-submitter never sees them.
18+
* `recall` additionally ORs in the #3424 admin override (#12716) — an
19+
* override admin is therefore the one non-submitter who does see it, and
20+
* is a caller `ApprovalService.recall` already authorises.
1821
*/
1922

2023
import { describe, it, expect } from 'vitest';
@@ -74,17 +77,40 @@ describe('sys_approval_request declared actions', () => {
7477
}
7578
});
7679

77-
it('the core decision levers OR in the admin override (#3424) so a stuck request is recoverable', () => {
78-
// approve / reject / reassign additionally show for a platform/tenant admin
79-
// (`record.viewer.can_override`) so an approval routed to an unstaffed
80-
// position — otherwise undecidable, locking the record forever — can be
81-
// rescued in-product. The secondary approver levers stay slot-only.
82-
for (const name of ['approval_approve', 'approval_reject', 'approval_reassign']) {
80+
it('the levers the admin override covers OR it in (#3424, +recall #12716) so a stuck request is recoverable', () => {
81+
// approve / reject / reassign / recall additionally show for a
82+
// platform/tenant admin (`record.viewer.can_override`) so an approval routed
83+
// to an unstaffed position — otherwise undecidable, locking the record
84+
// forever — can be rescued in-product. Recall joined the set in #12716: the
85+
// service has admitted the override caller on that endpoint since #3424
86+
// (`isOverrideActor`'s doc block names recall as one of the four levers),
87+
// and it is the only one that RELEASES the record without recording a
88+
// decision on someone else's behalf. The secondary approver levers and the
89+
// remaining submitter levers stay slot-only.
90+
for (const name of ['approval_approve', 'approval_reject', 'approval_reassign', 'approval_recall']) {
8391
expect(vis(name)).toContain('record.viewer.can_override');
8492
}
85-
for (const name of ['approval_send_back', 'approval_request_info']) {
93+
for (const name of ['approval_send_back', 'approval_request_info', 'approval_remind', 'approval_resubmit']) {
8694
expect(vis(name)).not.toContain('can_override');
8795
}
96+
97+
// Exhaustive, so a FIFTH lever cannot quietly join the override set without
98+
// this pin moving: the two loops above only constrain the names they name.
99+
expect(
100+
actions.filter((a) => vis(a.name).includes('can_override')).map((a) => a.name).sort(),
101+
'exactly these levers OR in the #3424 override',
102+
).toEqual(['approval_approve', 'approval_reassign', 'approval_recall', 'approval_reject']);
103+
104+
// ONE spelling of the arm, not four. This file has a spelling-drift history,
105+
// and a fourth wording of the same idea is its own defect — `toContain` on
106+
// the whole arm (leading ` || ` included) is what makes drift fail here
107+
// rather than in review.
108+
const OVERRIDE_ARM =
109+
' || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true';
110+
for (const name of ['approval_approve', 'approval_reject', 'approval_reassign', 'approval_recall']) {
111+
expect(vis(name), `${name} must spell the override arm exactly as its siblings do`)
112+
.toContain(OVERRIDE_ARM);
113+
}
88114
});
89115

90116
it('recall stays available while a returned request is still the submitter\'s to abandon', () => {

‎packages/plugins/plugin-approvals/src/sys-approval-request.object.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,14 @@ export const SysApprovalRequest = ObjectSchema.create({
328328
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
329329
// (the caller is a current pending approver — same check the service
330330
// authorizes a decision with, so position/team approvers resolve correctly),
331-
// submitter actions on `record.viewer.is_submitter`. The core decision levers
332-
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
333-
// (#3424) so a platform/tenant admin can rescue a request routed to an
331+
// submitter actions on `record.viewer.is_submitter`. The four levers the
332+
// #3424 override covers (approve/reject/reassign, and recall since #12716)
333+
// additionally OR in `record.viewer.can_override`
334+
// so a platform/tenant admin can rescue a request routed to an
334335
// unstaffed position — otherwise undecidable, locking the record forever — by
335-
// approving, rejecting, or reassigning it to a real approver. `viewer` is
336+
// approving, rejecting, reassigning it to a real approver, or recalling it
337+
// (the lever that releases the record without recording a decision nobody
338+
// made). `viewer` is
336339
// attached by getRequest/listRequests; where it is absent the predicate fails
337340
// closed.
338341
//
@@ -476,8 +479,13 @@ export const SysApprovalRequest = ObjectSchema.create({
476479
// Remind / recall (pending) and resubmit / recall (returned). These are the
477480
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
478481
// (server-computed on the current viewer). The service re-checks ownership;
479-
// the predicate keeps a non-submitter from ever seeing a button they cannot
480-
// use.
482+
// the predicate keeps a plain non-submitter from ever seeing a button they
483+
// cannot use.
484+
//
485+
// `recall` is the one exception, and it is not a widening: it ALSO ORs in
486+
// the #3424 admin override (#12716), because an override admin is a caller
487+
// `ApprovalService.recall` already authorises. Remind and resubmit keep no
488+
// override arm.
481489
{
482490
name: 'approval_remind',
483491
label: 'Send reminder',
@@ -508,9 +516,29 @@ export const SysApprovalRequest = ObjectSchema.create({
508516
],
509517
// Recall applies while the request is live for the submitter — pending
510518
// (withdraw) or returned (abandon the revision instead of resubmitting).
519+
//
520+
// The second arm is the #3424 admin override, spelled byte-identically to
521+
// the three core decision levers above (#12716). `ApprovalService.recall`
522+
// has admitted the override caller since #3424 — `isOverrideActor`'s own
523+
// doc block names recall as one of the four levers — so until this arm
524+
// landed, recall was the one authorised capability with no button: an
525+
// admin could approve or reject their way out of a stuck request (writing
526+
// a decision that did not happen) or reassign it, but could not withdraw.
527+
//
528+
// The override arm carries no status test of its own, on purpose, because
529+
// it does not need one and the siblings do not have one either: the flag
530+
// is already status-scoped where it is COMPUTED. `attachViewers` in
531+
// `approval-service.ts` sets
532+
// `can_override: row.status === 'pending' && isOverrideActor(...)` —
533+
// ANDed — so `record.viewer.can_override` can never be true off `pending`,
534+
// and this arm is pending-only in effect however CEL groups the
535+
// expression. Pinned in both directions in
536+
// `action-predicate-sparse-face.test.ts`, with the flag's own scoping
537+
// pinned against the real service in `approval-revise.test.ts`.
511538
visible:
512539
'has(record.status) && (record.status == "pending" || record.status == "returned")' +
513-
' && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
540+
' && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true' +
541+
' || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true',
514542
locations: ['record_section'],
515543
successMessage: 'Recalled.',
516544
refreshAfter: true,

0 commit comments

Comments
 (0)