Skip to content

Commit c1b7f2b

Browse files
feat(spec,plugin-approvals,rest): an unstaffed arm on the approvals inbox read returns an admin_rescue request to a caller who may override it (#22775)
Fixes #22725 Clause-②: yes (widening) An `onEmptyApprovers: 'admin_rescue'` request opens on a slate that names no person (the literal `position:NAME`, or nothing). A privileged admin may decide it, but nothing put it in that admin's queue. `approverId` matches a slot against the addresses the caller names, and an admin's own addresses never equal an unstaffed `position:NAME`. So no "My Pending" listed it, the bell read 0, and a `lockRecord: true` record stayed locked until someone looked under **All**. This PR adds the one server-side arm that returns such a request to a caller who may override it. It is one vertical PR, as triage's answer A on the card directs. ## What changed - **Contract** (`packages/spec/src/contracts/approval-service.ts`): `IApprovalService.listRequests`' filter gains ONE optional member, `unstaffed?: boolean`, with its docblock. `countRequests` takes the same filter type as `listRequests` (its first parameter), so it answers the member too. Nothing else on the filter moves: `approverId` matches exactly what it matched before. - **Service** (`packages/plugins/plugin-approvals`): - `awaitingRequestIds` is the one helper `listRequests` and `countRequests` both call. It returns `approverRequestIds(approverId)` ORed with `unstaffedOverridableRequestIds` when `unstaffed === true`, so the list and its paged `total` cannot disagree. - `unstaffedOverridableRequestIds` scans the pending requests inside the caller's organization wall (`SYSTEM_CTX`, the approver-index window). It keeps a row when the slate names no person AND `isOverrideActor(context, row.organization_id)` holds. - `isOverrideActor` is the only "admin" read, and there is no second notion. It is asked first as `visibleRequestIds` asks it, so a caller who may override nothing pays no scan. Then it is asked per row, as `attachViewers` asks it for `viewer.can_override`, so every added row reads `can_override: true`. - `slateNamesAPerson` (in `approver-address.ts`) is the shape test, now shared. The open path's three copies of the inline test that decides "decidable only by a privileged admin" read it too, so the slate the open path warns about and the slate the arm returns cannot drift apart. These are mechanical replacements and change no behaviour. - **REST door** (`packages/rest/src/rest-server.ts`): `unstaffed` joins `APPROVAL_REQUEST_LIST_PARAMS` and the single-valued multiplicity set. `readDeclaredQueryBoolean` reads it as `true` or `false` only. Anything else (`1`, `TRUE`, `yes`, an empty `?unstaffed=`) is refused `400 VALIDATION_FAILED` with `fields[0]` `{ field: 'unstaffed', code: 'invalid_boolean' }` through the door's `handleRouteError`. That is the envelope its `limit` / `offset` refusals answer, and the accepted set and code are runtime's `parseBooleanParam` (`?read=` on the notifications inbox). A repeated `?unstaffed=` gets the multiplicity `400`. - **Docs**: the approvals page's list-filter paragraph names the parameter and its OR semantics. - **Changeset**: `@objectstack/spec`, `@objectstack/plugin-approvals` and `@objectstack/rest`, all `minor`. ## The member's shape: the spec seat's contract call, on the four axes A NEW explicit member, ORed with `approverId`, never ANDed. I judge this a contract-shape call, not a product split. The card's done-when and the filed console follow-up (objectui#12102: "My Pending and the bell include the requests the server's rescue arm returns") already place the rescue requests in My Pending and the bell. - **Actual business need, measured.** The console asks My Pending as ONE server-side scope, and the bell as one polled request: `status=pending&approverId=IDS` in objectui `approvalRequestsDataSource.ts` (the `awaiting_me` scope, paged server-side through `limit` / `offset` / `total`) and `sharedUserFeeds.ts` (`useSharedPendingApprovalsCount`). Under an AND member, the console would need two requests merged on the client. A `ListView` over one data source cannot page that, and the bell would sum two counts. Under OR, the follow-up is one added parameter on each existing request. The badge objectui#12102 asks for is already on the wire: a rescue row reads `viewer.can_act: false, can_override: true`. - **Long-term soundness / contract-first.** `approverId` is NOT re-meant for override actors. That would silently change a published member for every caller, including callers that never asked. The new member is opt-in and declared, and the REST door declares it in its closed set. - **Keeping AI from writing it wrong.** The docblock states the OR, the "alone it selects rescues only", and the `false` equals absent rule, so `unstaffed: false` cannot be read as "exclude". The REST door refuses every spelling but `true` / `false`, so a guessed value never answers a `200` without the arm, a reply shaped exactly like "nothing to rescue". The member's name says what it selects, not which policy produced it. - **Startup scope.** One member, one arm, zero new gates and zero new row fields. The client SDK is not widened (see Acceptance notes). ## Which population the arm returns, and why The arm returns **pending requests whose current `pending_approvers` holds no user id and no email**: only `type:value` literals, or nothing at all. This is the test the open path makes before it warns "decidable only by a privileged admin", read off the stored slate as it stands, never off a flag stamped at open time. So the arm covers more than `admin_rescue`'s opening slot: - **in:** an `admin_rescue` open on a literal (`position:NAME`, `team:NAME`) or on an empty slate (an expression or `manager` rung that resolved to nobody). It also covers a `fallback` that degraded to `admin_rescue`, a stored pre-rename literal that only the override can decide, and a slate whose last named approver acted and left only literals (pinned: `unanimous` over `[u9, position:sales_manager]` joins the arm once u9 approves). - **in, deliberately:** a `position:NAME` slot whose position gained a holder AFTER the request opened. That holder also finds it under `approverId`, and the admin can still decide it, so the arm never shows a request its caller cannot act on. Dropping it would need a live directory read per literal, a second staffing notion beside the open path's. - **out:** a slate that still names a person, even one who has since left. A position that "becomes unstaffed later" is this case: a position approver resolves to its holders' user ids at open time, so the slate keeps naming them. Proving they left needs a per-slot directory read that neither this arm nor the open path makes. Such a request stays reachable under **All** and through `reassign`. ## Pins and evidence (measured on this branch) Service, through `listRequests` AND `countRequests` (`approval-service.test.ts`, `ApprovalService — the unstaffed arm (#22725)`, 9 cases): - an `admin_rescue` request (literal slot, and empty slate) appears for a tenant admin and a platform admin, with every row reading `can_override: true`; - it does not appear for a member. The flag adds nothing and refuses nothing, and the member pays no slate scan; - CONTROL: a staffed request's queue is unchanged. Its approver reads the same rows and total with and without the flag, the arm never claims it, and the admin's flagless My Pending is unchanged; - OR with `approverId`, across a paged window; `unstaffed: false` equals absent; a tenant admin's arm stops at their organization; per row, an org-less tenant admin reaches org-less rescues only; the slate as it stands; a finalized rescue leaves the arm. REST door, unit (`rest-server-approvals-unknown-filter.test.ts` §4): `true` / `false` reach both `listRequests` and `countRequests` beside `approverId`. Absent hands no arm. `1` / `TRUE` / `yes` / empty are refused `400 invalid_boolean`, and neither service method runs. Repeated is refused by the multiplicity gate. The closed-set preservation loop sends `unstaffed` one of its own values. Through the REST door on a real boot (`packages/qa/dogfood/test/approval-unstaffed-arm.dogfood.test.ts`, a new two-object fixture): the card's three pins on the paged list and its `total` (the bell's number), plus the door's `400`. Ablations, each run from a committed state with the restore proven by blob equal to HEAD and an empty `git diff HEAD`: - arm disabled (`if (filter?.unstaffed !== true || true) return byApprover;`): 6 of the 8 service cases red. The 2 that stay green (`false` equals absent, the tenant wall) do not depend on the arm being on. - per-row override test deleted: the org-less tenant-admin case red, the other 8 green. - REST reader replaced by the bare `q.unstaffed === 'true'`: 5 door cases red (the four refusals, and "absent hands no arm"). - dogfood, through `dist/`: the arm mutation rebuilt into `plugin-approvals`. `ablation-dist-preflight` confirmed the marker in `dist/index.js` and `dist/index.mjs`, and the pin went red on the admin's `approverId`-plus-`unstaffed` read. After the restore, the rebuilt `dist/` had the marker absent and the tree clean. Suites (full, not filtered), measured at the PR head `bb112188a9` (after merging `origin/main` at `996aa86e0a`): - `pnpm --filter @objectstack/plugin-approvals test`: 71 files, 1032 tests passed; - `pnpm --filter @objectstack/rest test`: 272 files, 5247 passed, 326 skipped; - dogfood: `approval-unstaffed-arm`, plus the two neighbouring approvals pins (`approval-override-composite-pin`, `my-pending-position-address`): 3 of 3 passed; - `typecheck` exit 0 for `plugin-approvals` and `rest` (both run `check:test-typecheck`, which covers their test files), for `spec`, and for `dogfood`, whose `tsc` program lists both new files; - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. `api-surface` records the interface by name, so the member moves no snapshot. Gates at `bb112188a9`: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` reports 114 derived families, 114 run, 0 NOT-MEASURED, 0 UNRUN. Every one carries a recorded exit code, and all are 0. - The six artifact-roster families whose roster sits beside my paths are also 0: `check-changeset-fixed`, `check:meta-url-spelling`, `check:error-code-casing`, `check:filter-alias-parity`, `check:engine-double-contract`, `check:route-ledger-census`. - The CI-only jobs and type-check lanes that derivation names are left to CI. - Lint, narrowed and proven. eslint's own population is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (`eslint.config.mjs`), so the two Markdown files are outside it. All 8 changed TypeScript files were linted with `--no-inline-config --format json`: 8 files, 0 errors, 0 warnings. The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - **Client SDK not widened.** `@objectstack/client`'s `approvals.listRequests` hand-writes its filter type, and it was already a subset of the contract's (no `q`, `limit` or `offset`). The console reads the door through its own `approvalsApi`, so there is no measured consumer of the SDK method for this member. It is noted here, not filed. Carrier: none. - **The boolean reader is a second spelling of runtime's rule.** `@objectstack/rest` cannot import `@objectstack/runtime`'s `parseBooleanParam` (runtime depends on rest). The reader's docblock names the rule it mirrors, the accepted set, and the code. Carrier: none. - **`listRequests`' `limit` docblock in the contract**: corrected in this PR (patch round 1, `640aed576d`). It said an `approverId` / status-array filter "post-filters in memory", with the window applied after filtering, which was false at the head. It now says what the head serves: the window is engine-level for every filter (a status array pushes down as `$in`), and the two "who acts next" arms (`approverId`, `unstaffed`) first resolve to a bounded id set (the approver index and the pending scan, each within `APPROVER_INDEX_CAP`), with the window applied to that set engine-side. - **File surface beyond the claim's list:** the docs paragraph (`content/docs/automation/approvals.mdx`) and the dogfood pin plus its fixture, which carry the claim's "each pin runs through the service and through the REST door". --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a8f24b0 commit c1b7f2b

10 files changed

Lines changed: 788 additions & 25 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": minor
4+
"@objectstack/rest": minor
5+
---
6+
7+
feat(spec,plugin-approvals,rest): the approvals inbox read gets an `unstaffed` arm, so an `admin_rescue` request is in the queue of a caller who may override it
8+
9+
Clause-②: yes (widening)
10+
11+
- **What is new.** `IApprovalService.listRequests`' filter declares an optional `unstaffed?: boolean`. `countRequests` takes the same filter, so it answers it too. `GET /api/v1/approvals/requests` accepts it as the declared query parameter `?unstaffed=true|false`.
12+
- **What it selects.** The PENDING requests whose current slate names no person: no user id and no email, only `type:value` literals such as `position:<p>`, or nothing at all. This is what `onEmptyApprovers: 'admin_rescue'` (the default) opens. The test is the one the open path makes before it warns "decidable only by a privileged admin", applied to the slate as it stands. A slate whose last named approver acted and left only literals matches too. A slate that still names a person never matches, even if that person has left.
13+
- **Who it returns them to.** Only a caller who may override them, judged by the privileged-override rule that admits the override decision itself. Every row it adds reads `viewer.can_override: true`, and a tenant admin's arm stops at their organization. For anyone else the member adds nothing and refuses nothing.
14+
- **How it composes.** It is ORed with `approverId`, never ANDed. `?status=pending&approverId=<my identities>&unstaffed=true` answers "my pending approvals, rescues included" in one call, paged server-side, and `total` counts both arms. Without `approverId` it selects the rescue requests alone. `unstaffed=false` is the same as leaving it out.
15+
- **What is refused.** Any value but `true` or `false` (`1`, `TRUE`, an empty `?unstaffed=`) answers `400 VALIDATION_FAILED` with `fields[0]` `{ field: 'unstaffed', code: 'invalid_boolean' }`, and a repeated `?unstaffed=` answers the route's multiplicity `400`. Nothing changes for a caller who does not send it: `approverId` matches exactly what it matched before.
16+
- **Before this.** The request was in no queue. `approverId` matches a slot against the addresses a caller names, and an override actor's addresses never equal an unstaffed `position:<p>` literal. So no "My Pending" listed it, no bell counted it, and a `lockRecord: true` record stayed locked until an admin browsed the unfiltered list.

‎content/docs/automation/approvals.mdx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,28 @@ position under as their own identity. The deprecated pre-rename prefix is no
453453
alias of it (ADR-0090 D3): it matches only a slot stored under that exact
454454
spelling, and no holder acts under it.
455455
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
456-
`offset`.
456+
`offset`, and `unstaffed`.
457+
458+
`unstaffed=true` adds the pending requests whose slate names no person: only
459+
unstaffed literals such as `position:<name>`, or nothing at all. This is what
460+
`admin_rescue` opens. They
461+
are added only for a caller who may override them: an admin with override
462+
authority (`admin_full_access`, or `organization_admin` within their
463+
organization). Every such row reads `viewer.can_override: true`. For anyone else the parameter
464+
adds nothing. It is ORed with `approverId`, never ANDed, so one call answers
465+
"my pending approvals, rescues included", and its `total` counts both:
466+
467+
```bash
468+
curl -b cookies.txt \
469+
"https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123&unstaffed=true"
470+
```
471+
472+
It reads `true` or `false` only (`false` is the same as leaving it out); any
473+
other value is refused with `400`. The test reads the slate, not the directory:
474+
a `position:<name>` slot whose position gained a holder after the request opened
475+
still matches (that holder also finds it under `approverId`), and a slate that
476+
still names a person never matches, even if that person has since left.
477+
Reassign such a request from the **All** view.
457478

458479
<Callout type="warn">
459480
**`approverId` is a filter, not authorization.** What you may see is decided

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

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,200 @@ describe('ApprovalService — admin override (#3424)', () => {
20662066
});
20672067
});
20682068

2069+
// ── #22725: the `unstaffed` arm of the inbox read ────────────────────────────
2070+
//
2071+
// An `onEmptyApprovers: 'admin_rescue'` request opens on a slate that names no
2072+
// person, so it was in nobody's queue: `approverId` matches a slot against the
2073+
// addresses a caller names, and an override actor's addresses never equal an
2074+
// unstaffed `position:<p>` literal. Only the unfiltered list showed it. The
2075+
// `unstaffed` member is the arm that returns it to the callers who may decide
2076+
// it. It is ORed with `approverId`, and both `listRequests` and
2077+
// `countRequests` answer it.
2078+
//
2079+
// Every case reads BOTH faces (the list and its count), because the console's
2080+
// paged inbox shows the one and the bell shows the other.
2081+
describe('ApprovalService — the unstaffed arm (#22725)', () => {
2082+
let engine: ReturnType<typeof makeFakeEngine>;
2083+
let svc: ApprovalService;
2084+
let n = 0;
2085+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
2086+
2087+
const PLATFORM_ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any;
2088+
const TENANT_ADMIN = { userId: 'owner', tenantId: 't1', positions: [], permissions: ['organization_admin'] } as any;
2089+
const OTHER_TENANT_ADMIN = { userId: 'owner2', tenantId: 't2', positions: [], permissions: ['organization_admin'] } as any;
2090+
const MEMBER = { userId: 'nobody', tenantId: 't1', positions: [], permissions: [] } as any;
2091+
const APPROVER = { userId: 'u9', tenantId: 't1', positions: [], permissions: [] } as any;
2092+
2093+
/** A node routed to a position nobody holds: the `position:sales_manager` literal. */
2094+
const stuck = (recordId: string) => ({
2095+
object: 'opportunity', recordId, runId: `run_${recordId}`, nodeId: 'approve_step',
2096+
flowName: 'deal_approval',
2097+
config: { approvers: [{ type: 'position' as const, value: 'sales_manager' }], behavior: 'first_response' as const, lockRecord: true },
2098+
record: { id: recordId, amount: 100 },
2099+
});
2100+
/** An expression that resolves to nobody: `admin_rescue` opens on an EMPTY slate. */
2101+
const empty = (recordId: string) => ({
2102+
...stuck(recordId),
2103+
config: { approvers: [{ type: 'expression' as const, value: 'vars.picked' }], behavior: 'first_response' as const, lockRecord: true },
2104+
variables: { picked: [] },
2105+
});
2106+
/** A staffed node: a concrete user on the slate. */
2107+
const staffed = (recordId: string, user = 'u9') => ({
2108+
...openInput([user]), recordId, runId: `run_${recordId}`, record: { id: recordId, amount: 100 },
2109+
});
2110+
2111+
const ids = (rows: Array<{ id: string }>) => rows.map(r => r.id).sort();
2112+
/** Both faces of one filter, as one reader: the list's ids and the count. */
2113+
const read = async (filter: Record<string, unknown>, ctx: any) => {
2114+
const rows = await svc.listRequests(filter as any, ctx);
2115+
const total = await svc.countRequests(filter as any, ctx);
2116+
return { ids: ids(rows), total, rows };
2117+
};
2118+
2119+
beforeEach(() => {
2120+
engine = makeFakeEngine();
2121+
n = 0;
2122+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
2123+
});
2124+
2125+
it('an admin_rescue request appears for an override actor, on both faces', async () => {
2126+
const onLiteral = await svc.openNodeRequest(stuck('opp_literal'), CTX) as any;
2127+
const onNothing = await svc.openNodeRequest(empty('opp_empty'), CTX) as any;
2128+
expect(onLiteral.pending_approvers).toEqual(['position:sales_manager']);
2129+
expect(onNothing.pending_approvers).toEqual([]);
2130+
const control = await svc.openNodeRequest(staffed('opp_staffed'), CTX) as any;
2131+
2132+
for (const admin of [TENANT_ADMIN, PLATFORM_ADMIN]) {
2133+
const got = await read({ status: 'pending', unstaffed: true }, admin);
2134+
expect(got.ids).toEqual([onLiteral.id, onNothing.id].sort());
2135+
expect(got.total).toBe(2);
2136+
expect(got.ids).not.toContain(control.id);
2137+
// Every row the arm adds is one the caller may override.
2138+
for (const row of got.rows) {
2139+
expect(row.viewer).toMatchObject({ can_act: false, can_override: true });
2140+
}
2141+
}
2142+
});
2143+
2144+
it('it does not appear for a member: the flag adds nothing and refuses nothing', async () => {
2145+
await svc.openNodeRequest(stuck('opp_literal'), CTX);
2146+
await svc.openNodeRequest(empty('opp_empty'), CTX);
2147+
const mine = await svc.openNodeRequest(staffed('opp_mine', 'nobody'), CTX) as any;
2148+
2149+
const slateScans = () => engine._finds.filter(f => f.object === 'sys_approval_request'
2150+
&& Array.isArray(f.options?.fields) && f.options.fields.includes('pending_approvers')).length;
2151+
const before = slateScans();
2152+
expect(await read({ status: 'pending', unstaffed: true }, MEMBER)).toMatchObject({ ids: [], total: 0 });
2153+
// A caller who may override nothing is answered without the arm's scan.
2154+
expect(slateScans()).toBe(before);
2155+
// Beside the member's own identities, the arm leaves their queue exactly as
2156+
// `approverId` alone answers it.
2157+
const withFlag = await read({ status: 'pending', approverId: ['nobody'], unstaffed: true }, MEMBER);
2158+
const without = await read({ status: 'pending', approverId: ['nobody'] }, MEMBER);
2159+
expect(withFlag.ids).toEqual([mine.id]);
2160+
expect(withFlag.total).toBe(1);
2161+
expect(without.ids).toEqual(withFlag.ids);
2162+
expect(without.total).toBe(withFlag.total);
2163+
});
2164+
2165+
it('CONTROL: a staffed request\'s queue is unchanged', async () => {
2166+
await svc.openNodeRequest(stuck('opp_literal'), CTX);
2167+
const control = await svc.openNodeRequest(staffed('opp_staffed'), CTX) as any;
2168+
2169+
// Its approver's queue reads the same with and without the arm.
2170+
for (const filter of [
2171+
{ status: 'pending', approverId: ['u9'] },
2172+
{ status: 'pending', approverId: ['u9'], unstaffed: true },
2173+
]) {
2174+
expect(await read(filter, APPROVER)).toMatchObject({ ids: [control.id], total: 1 });
2175+
}
2176+
// And an override actor's rescue arm never claims it.
2177+
const rescue = await read({ status: 'pending', unstaffed: true }, TENANT_ADMIN);
2178+
expect(rescue.ids).not.toContain(control.id);
2179+
// Without the arm, the override actor's "My Pending" is what it was.
2180+
expect(await read({ status: 'pending', approverId: ['owner'] }, TENANT_ADMIN)).toMatchObject({ ids: [], total: 0 });
2181+
});
2182+
2183+
it('with approverId the arms are ORed: one read answers "my pending, rescues included"', async () => {
2184+
const stuckReq = await svc.openNodeRequest(stuck('opp_literal'), CTX) as any;
2185+
const adminOwn = await svc.openNodeRequest(staffed('opp_admin_own', 'owner'), CTX) as any;
2186+
await svc.openNodeRequest(staffed('opp_other'), CTX);
2187+
2188+
const both = await read({ status: 'pending', approverId: ['owner'], unstaffed: true }, TENANT_ADMIN);
2189+
expect(both.ids).toEqual([stuckReq.id, adminOwn.id].sort());
2190+
expect(both.total).toBe(2);
2191+
// The paged window covers the union too: two pages of one.
2192+
const page1 = await svc.listRequests({ status: 'pending', approverId: ['owner'], unstaffed: true, limit: 1, offset: 0 }, TENANT_ADMIN);
2193+
const page2 = await svc.listRequests({ status: 'pending', approverId: ['owner'], unstaffed: true, limit: 1, offset: 1 }, TENANT_ADMIN);
2194+
expect(ids([...page1, ...page2])).toEqual(both.ids);
2195+
});
2196+
2197+
it('`unstaffed: false` is the same as absent', async () => {
2198+
await svc.openNodeRequest(stuck('opp_literal'), CTX);
2199+
await svc.openNodeRequest(staffed('opp_staffed'), CTX);
2200+
const absent = await read({ status: 'pending' }, TENANT_ADMIN);
2201+
const off = await read({ status: 'pending', unstaffed: false }, TENANT_ADMIN);
2202+
expect(off.ids).toEqual(absent.ids);
2203+
expect(off.total).toBe(absent.total);
2204+
expect(absent.total).toBe(2);
2205+
});
2206+
2207+
it('a tenant admin\'s rescue queue stops at their organization; a platform admin\'s crosses it', async () => {
2208+
const t1Stuck = await svc.openNodeRequest(stuck('opp_literal'), CTX) as any; // organization t1
2209+
2210+
expect(await read({ status: 'pending', unstaffed: true }, OTHER_TENANT_ADMIN)).toMatchObject({ ids: [], total: 0 });
2211+
const crossing = { ...PLATFORM_ADMIN, tenantId: 't2' };
2212+
expect(await read({ status: 'pending', unstaffed: true }, crossing)).toMatchObject({ ids: [], total: 0 });
2213+
// The platform admin's own read is walled by its context organization the
2214+
// way every list read is (`buildRequestWhere`); with no organization on
2215+
// the context, the arm reaches every organization's rescue queue.
2216+
const unscoped = { userId: 'root', positions: [], permissions: ['admin_full_access'] } as any;
2217+
expect(await read({ status: 'pending', unstaffed: true }, unscoped)).toMatchObject({ ids: [t1Stuck.id], total: 1 });
2218+
});
2219+
2220+
it('per row, the arm returns only what the caller may override: an org-less tenant admin reaches org-less rescues only', async () => {
2221+
const t1Stuck = await svc.openNodeRequest(stuck('opp_literal'), CTX) as any; // organization t1
2222+
const globalStuck = await svc.openNodeRequest(stuck('opp_global'), SYS) as any; // no organization
2223+
expect(engine._tables['sys_approval_request'].find(r => r.id === globalStuck.id)?.organization_id ?? null).toBeNull();
2224+
2225+
// No organization on the context: the list read is unwalled, and the
2226+
// visibility read lets this caller see every row. Only the per-row
2227+
// override test keeps the t1 request, which this caller may NOT
2228+
// override, out of the arm.
2229+
const orgLessTenantAdmin = { userId: 'owner', positions: [], permissions: ['organization_admin'] } as any;
2230+
const got = await read({ status: 'pending', unstaffed: true }, orgLessTenantAdmin);
2231+
expect(got).toMatchObject({ ids: [globalStuck.id], total: 1 });
2232+
expect(got.ids).not.toContain(t1Stuck.id);
2233+
expect(got.rows[0].viewer).toMatchObject({ can_override: true });
2234+
});
2235+
2236+
it('reads the slate as it stands: a slate left holding only literals joins the arm', async () => {
2237+
const mixed = await svc.openNodeRequest({
2238+
...stuck('opp_mixed'),
2239+
config: {
2240+
approvers: [{ type: 'user' as const, value: 'u9' }, { type: 'position' as const, value: 'sales_manager' }],
2241+
behavior: 'unanimous' as const,
2242+
lockRecord: true,
2243+
},
2244+
}, CTX) as any;
2245+
expect(mixed.pending_approvers).toEqual(['u9', 'position:sales_manager']);
2246+
expect((await read({ status: 'pending', unstaffed: true }, TENANT_ADMIN)).ids).toEqual([]);
2247+
2248+
// u9 approves; the unanimous tally keeps the request open on the literal alone.
2249+
await svc.decideNode(mixed.id, { decision: 'approve', actorId: 'u9' }, APPROVER);
2250+
const after = await svc.getRequest(mixed.id, TENANT_ADMIN);
2251+
expect(after?.status).toBe('pending');
2252+
expect(after?.pending_approvers).toEqual(['position:sales_manager']);
2253+
expect(await read({ status: 'pending', unstaffed: true }, TENANT_ADMIN)).toMatchObject({ ids: [mixed.id], total: 1 });
2254+
});
2255+
2256+
it('a finalized rescue leaves the arm', async () => {
2257+
const req = await svc.openNodeRequest(stuck('opp_literal'), CTX) as any;
2258+
await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
2259+
expect(await read({ unstaffed: true }, TENANT_ADMIN)).toMatchObject({ ids: [], total: 0 });
2260+
});
2261+
});
2262+
20692263
// ── approval_comment: the declared reply against the route it posts to ──────
20702264
//
20712265
// `sys_approval_request` declares the thread reply as the action

0 commit comments

Comments
 (0)