Skip to content

Commit 6d487d2

Browse files
fix(plugin-approvals): My Pending lists a position-routed request under either address spelling (#21378)
Fixes #21350 Clause-②: no ## What changes The approvals inbox's "My Pending" (`GET /api/v1/approvals/requests?approverId=...`) now finds a request routed to a position for every user who holds that position, whichever spelling of the position address the client sends. The acting path's position-address equivalence now lives in one module, `packages/plugins/plugin-approvals/src/approver-address.ts`, and three readers use it: - `resolveActor` (the acting path) admits exactly the identities it admitted before. A pin compares it against the old predicate. - `approverRequestIds` (the "My Pending" filter): a `role:P` or `position:P` value matches the stored slot under both spellings. No other spelling folds. - `visibleRequestIds` (the participant gate behind every approvals read): a "current approver" now also counts the slot addresses of every position on the caller's server-resolved `context.positions`. No second fold sits beside any of them, and nothing is client-specific. ## Why the participant gate is in this PR Triage's ruling named the list filter. I measured on a real boot at `origin/main` 5fd4855, before the fix. The scene was a `bootStack` app whose flow routes to a position nobody holds at open time, with users staffed into it after submission: | caller | approverId with `role:P` | approverId with `position:P` | GET the request | approve as `position:P` | |---|---|---|---|---| | staffed submitter (the card's HotCRM shape) | 0 rows | 1 row | not read | not read | | staffed reviewer, not the submitter, not an admin | 0 rows | 0 rows | 404 | 200 | | bystander who holds a different position | 0 rows | 0 rows | not read | 403 `FORBIDDEN` | The first row is the card. The second row shows that folding the filter alone would not reach a staffed reviewer who neither submitted the request nor has admin standing. The participant gate counted a "current approver" by bare user id, so the request was hidden under every spelling and the detail read answered 404, even though that same user could approve it. The ruling's pin ("a user staffed into the position sees the request in My Pending under either spelling") needs both halves. The gate reads the same `positionAddresses` the acting path reads. So it adds a request only for a caller whom `resolveActor` lets act on that slot. After the fix, in the same scene: the reviewer and the submitter each get 1 row under both spellings, the reviewer's detail read answers 200, the bystander gets 0 rows, and the acting path behaves as before. ## The spellings `resolveActor` accepts For a non-system caller, `resolveActor` accepts three identities: - the bare user id; - `position:P` or `role:P`, for each P in the server-resolved `context.positions`; - an email that the caller's own `sys_user` row carries (a case-insensitive read). Only the second is a spelling equivalence, and it is the only one folded. User ids and emails already match literally, and the console sends both. `team:P`, `org_membership_level:P` and a bare name fold onto nothing (pinned). ## Pins - `plugin-approvals/src/approver-address.test.ts` pins the equivalence itself: - both prefixes, canonical first; - the split is at the first prefix only; - eight non-position addresses are equal only to themselves. - `plugin-approvals/src/approval-service.test.ts`, describe "My Pending position addresses (#21350)": - a holder lists, counts and reads the request under both spellings; - a 15.x `role:P` slot is found under `position:P`; - a non-holder sees nothing; - a spelling the acting path does not admit folds onto nothing; - acting-path control: the holder decides, the bystander is refused; - `resolveActor` is compared with the old predicate, verbatim, over a 15 x 7 matrix that includes non-string positions and position names that contain a colon. - `qa/dogfood/test/my-pending-position-address.dogfood.test.ts` (plus its fixture) drives the real route on a booted app: - reviewer and submitter, under both spellings; - reviewer detail read answers 200; - bystander gets an empty list and a 404; - `team:P` and `org_membership_level:P` fold onto nothing (read as admin); - the bystander's approve answers 403 `FORBIDDEN`; - the reviewer's approve answers 200, and the request is `approved`. ## Ablations The fix was committed first (4007e6c). Each ablation went through `scripts/ablation-replace.mjs` in WRAP mode. For every ablation the anchor hit exactly once and the blob changed, and the restore was proven: blob equals HEAD, and `git diff HEAD` is empty. | ablation | unit pins | dogfood pin | |---|---|---| | A1: list filter matches literally | 3 red: both spellings, 15.x slot, positive half of the non-admitted-spelling pin | red: reviewer under `role:P` | | A2: participant gate keyed on the user id only | 2 red: both spellings, 15.x slot | red: reviewer under `role:P` | | A3: normalizer widened with `team:` | 6 red, including the `resolveActor` oracle | red: `team:P` must not fold | All three went red, as predicted. The unit pins import the service source relatively, and dogfood's `isolated` project aliases `@objectstack/plugin-approvals` to `src/`, so no `dist/` leg applies. ## Tests (head d788026) - `pnpm --filter @objectstack/plugin-approvals test`: 55 files, 842 tests passed. - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0. `check:test-typecheck` OK, with no new debt; the new test files are in the `tsconfig.test.json` program (`--listFilesOnly`). - Dogfood `--project isolated`: the new pin plus the two sibling approval pins (`approval-override-composite-pin`, `approval-snapshot-masked-field`) all passed. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. Both new files are in the program. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 94 commands at d788026, and all 94 exited 0. Each exit code was captured before any pipe. `--ran` reconciled 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. - In the first pass, `check:skill-examples` and `check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (exit 3). I built `@objectstack/client-react` and the seven packages without a `dist/`, then re-ran the whole union at the final head. ## Acceptance notes - **Not changed here, measured on the fixed branch (reported to the seat, not filed by this PR):** - The staffed reviewer still cannot decide from the console. The served `viewer.can_act` is `false`, because `attachViewers` keys it on the user id. The server-declared approve action sends no `actorId`, which defaults to the user id and answers 403 "not a pending approver". Sending `role:P` also answers 403, because the slot check is literal. Only `actorId: position:P` succeeds. - After deciding as `position:P`, the decider's detail read answers 404. `sys_approval_action.actor_id` records `position:P`, and the gate's "already acted" probe keys on the user id. - **Docs:** `content/docs/automation/approvals.mdx` now says that the inbox counts a held position's literal slot, that a position literal matches under both spellings, and that the participant gate counts holders. The deprecated prefix is described in words, because `check:role-word` holds that file's count of the word. - **ADR-0090 D3 note:** the `role:` arm is the deprecated spelling, which the ADR retires without an alias window. This PR applies triage's ruling and does not reopen it. With the equivalence in one module, retiring `role:` once the console sends `position:` is a one-line edit. - **objectui:** the docblock of `approverIdentities()` in `packages/app-shell/src/hooks/sharedUserFeeds.ts`, at the pinned `.objectui-sha` 31971ff1e, says: "The `role:` prefix is the SERVER's addressing scheme for `pending_approvers` and stays as it is". The server stores `position:P`. Per the claim, the coordination child is the seat's to file; this PR does not touch objectui. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d956910 commit 6d487d2

8 files changed

Lines changed: 561 additions & 19 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
The approvals inbox's "My Pending" now lists a request routed to a position for the users who hold that position, whichever spelling of the position address the client asks for
6+
7+
Clause-②: no
8+
9+
A request whose approver position nobody held when it opened keeps the literal `position:<name>` slot. A user staffed into that position afterwards could already decide it, by naming `position:<name>` as the actor. `resolveActor` admits a holder under `position:<name>` and under `role:<name>` (the deprecated pre-rename spelling) as the caller's own identity, but the decision's slot test is literal: on that slot, `role:<name>` or no actor at all answers 403. The list read did not agree with either half.
10+
11+
- `GET /api/v1/approvals/requests?approverId=…` matched each value literally. The stock console sends `role:<name>` for every position the session carries, so the request never appeared in "My Pending". A position address now matches under both spellings `resolveActor` admits a holder under, and no others. A `team:`, `org_membership_level:` or bare-name value still matches only itself.
12+
- The participant gate behind every approvals read counted a "current approver" by user id alone. A holder of the position who neither submitted the request nor holds admin standing got an empty list under both spellings and a `404` on `GET /api/v1/approvals/requests/:id`, though their approve call naming `position:<name>` succeeded. The gate now also counts the slot addresses of every position on the caller's server-resolved context. A request becomes visible only to someone who can decide it.
13+
- The decision routes are unchanged. They admit exactly the identities they admitted before, and a pin compares them against the previous predicate.
14+
15+
A caller who sent the stored `position:<name>` spelling and was already the submitter or an admin sees no change.

‎content/docs/automation/approvals.mdx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,8 @@ and nothing can move it, so the run parks forever.
386386

387387
The approver's surface is the **Approvals Inbox** — in the stock console, the
388388
**Approvals** entry in the account menu. It lists what is waiting on the current
389-
user (`status = pending`, `pending_approvers` containing them) and it is the only
389+
user (`status = pending`, `pending_approvers` containing them — their user id,
390+
or the `position:<name>` literal of a position they hold) and it is the only
390391
surface that carries the **decision actions**: approve, reject, return, reassign,
391392
request info, delegate, remind, override.
392393

@@ -415,15 +416,19 @@ curl -b cookies.txt \
415416
`approverId` accepts a user id, an email, or a `<type>:<value>` approver literal
416417
(`position:finance_manager` — the form an entry falls back to when it resolves
417418
to no users) — and takes several values (comma-separated or repeated) to cover
418-
a person's identities in one call.
419+
a person's identities in one call. A position literal matches under both
420+
spellings the service admits a holder of that position under as their own
421+
identity: `position:<name>`, and the deprecated pre-rename prefix the stock
422+
console still sends — so either one finds the same requests.
419423
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
420424
`offset`.
421425

422426
<Callout type="warn">
423427
**`approverId` is a filter, not authorization.** What you may see is decided
424428
separately: a request is visible to its **participants** — the submitter, a
425-
current approver, and anyone who has already acted on it (a past approver whose
426-
slot has moved on, a commenter). So omitting `approverId` returns *your*
429+
current approver (counting anyone who holds the position a `position:<name>`
430+
slot names, since the decision routes admit them under it), and anyone who has
431+
already acted on it (a past approver whose slot has moved on, a commenter). So omitting `approverId` returns *your*
427432
requests, not every request in the tenant. Admins with override authority
428433
(`admin_full_access`, or `organization_admin` within their org) see all of them
429434
— that is what the "all requests" view is for.

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3587,6 +3587,142 @@ describe('ApprovalService — participant visibility (#3590)', () => {
35873587
});
35883588
});
35893589

3590+
// ── "My Pending" reads the acting path's position addresses (#21350) ───
3591+
//
3592+
// A request routed to a position nobody held when it opened keeps the literal
3593+
// `position:<p>` slot (a 15.x-era one reads `role:<p>`), and the console asks
3594+
// "My Pending" under `role:<p>`. `resolveActor` has always admitted a holder of
3595+
// `p` under either spelling, but the list filter matched the caller's
3596+
// `approverId` literally and the participant gate keyed on the user id alone —
3597+
// so the request was missing from the inbox of the very user who could decide
3598+
// it. All three now read ONE equivalence (`approver-address.ts`).
3599+
describe('ApprovalService — "My Pending" position addresses (#21350)', () => {
3600+
const svcFor = (engine: any) => {
3601+
let n = 0;
3602+
return new ApprovalService({ engine, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
3603+
};
3604+
/** A signed-in caller whose server-resolved `positions` are `positions`. */
3605+
const holding = (userId: string, positions: unknown[]) =>
3606+
({ userId, tenantId: 't1', positions, permissions: [] }) as any;
3607+
/** Staffed into the routed position; neither the submitter nor an admin. */
3608+
const REVIEWER = holding('u_reviewer', ['sales_manager']);
3609+
/** Holds a position — just not the routed one. */
3610+
const BYSTANDER = holding('u_bystander', ['finance']);
3611+
const SPELLINGS = ['role:sales_manager', 'position:sales_manager'] as const;
3612+
3613+
/** Routed to a position the fake directory staffs with nobody → a literal slot. */
3614+
const routedTo = (type: 'position' | 'role', value = 'sales_manager') => ({
3615+
object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step',
3616+
flowName: 'deal_approval',
3617+
config: { approvers: [{ type: type as any, value }], behavior: 'first_response' as const },
3618+
record: { id: 'opp1', amount: 100 },
3619+
});
3620+
/** Open the scene. An empty slate under the default policy still opens a row; an auto-approval is a broken scene. */
3621+
const open = async (svc: ApprovalService, input: ReturnType<typeof routedTo>) => {
3622+
const out = await svc.openNodeRequest(input, CTX);
3623+
if (!('id' in out)) throw new Error('scene did not open: the empty slate auto-approved');
3624+
return out;
3625+
};
3626+
3627+
it('a holder of the position finds the request under EITHER spelling — list, count and the request itself', async () => {
3628+
const engine = makeFakeEngine();
3629+
const svc = svcFor(engine);
3630+
const req = await open(svc, routedTo('position')); // submitter u1
3631+
expect(req.pending_approvers).toEqual(['position:sales_manager']);
3632+
3633+
for (const spelling of SPELLINGS) {
3634+
// The console's own identity list: user id, then the position address.
3635+
const filter = { status: 'pending' as const, approverId: ['u_reviewer', spelling] };
3636+
const listed = await svc.listRequests(filter, REVIEWER);
3637+
expect(listed.map(r => r.id), `listed under '${spelling}'`).toEqual([req.id]);
3638+
expect(await svc.countRequests(filter, REVIEWER), `counted under '${spelling}'`).toBe(1);
3639+
}
3640+
expect(await svc.getRequest(req.id, REVIEWER)).not.toBeNull();
3641+
});
3642+
3643+
it('a 15.x-era `role:` slot is found under the `position:` spelling too', async () => {
3644+
const engine = makeFakeEngine();
3645+
const svc = svcFor(engine);
3646+
// `role` is the deprecated approver type; its literal keeps the authored spelling.
3647+
const req = await open(svc, routedTo('role'));
3648+
expect(req.pending_approvers).toEqual(['role:sales_manager']);
3649+
3650+
for (const spelling of SPELLINGS) {
3651+
const listed = await svc.listRequests({ status: 'pending', approverId: ['u_reviewer', spelling] }, REVIEWER);
3652+
expect(listed.map(r => r.id), `listed under '${spelling}'`).toEqual([req.id]);
3653+
}
3654+
});
3655+
3656+
it('negative control: a user who does not hold the position sees nothing, under either spelling', async () => {
3657+
const engine = makeFakeEngine();
3658+
const svc = svcFor(engine);
3659+
const req = await open(svc, routedTo('position'));
3660+
3661+
for (const spelling of SPELLINGS) {
3662+
const filter = { status: 'pending' as const, approverId: ['u_bystander', spelling] };
3663+
expect(await svc.listRequests(filter, BYSTANDER), `listed under '${spelling}'`).toEqual([]);
3664+
expect(await svc.countRequests(filter, BYSTANDER), `counted under '${spelling}'`).toBe(0);
3665+
}
3666+
expect(await svc.listRequests(undefined, BYSTANDER)).toEqual([]);
3667+
expect(await svc.getRequest(req.id, BYSTANDER)).toBeNull();
3668+
});
3669+
3670+
it('negative control: a spelling the acting path does not admit folds onto nothing', async () => {
3671+
const engine = makeFakeEngine();
3672+
const svc = svcFor(engine);
3673+
const req = await open(svc, routedTo('position'));
3674+
3675+
// SYS sees every row, so a miss here is the FILTER's verdict alone.
3676+
expect((await svc.listRequests({ approverId: 'role:sales_manager' }, SYS)).map(r => r.id)).toEqual([req.id]);
3677+
for (const address of ['team:sales_manager', 'org_membership_level:sales_manager', 'sales_manager']) {
3678+
expect(await svc.listRequests({ approverId: address }, SYS), `'${address}' must not fold`).toEqual([]);
3679+
}
3680+
});
3681+
3682+
it('the acting path is unchanged: the holder decides under the stored spelling, the bystander is refused', async () => {
3683+
const engine = makeFakeEngine();
3684+
const svc = svcFor(engine);
3685+
const req = await open(svc, routedTo('position'));
3686+
3687+
await expect(
3688+
svc.decideNode(req.id, { decision: 'approve', actorId: 'position:sales_manager' }, BYSTANDER),
3689+
).rejects.toThrow(/^FORBIDDEN: cannot act as 'position:sales_manager'/);
3690+
3691+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'position:sales_manager' }, REVIEWER);
3692+
expect(out.finalized).toBe(true);
3693+
expect(out.request.status).toBe('approved');
3694+
});
3695+
3696+
// `resolveActor` must admit EXACTLY what it admitted before the equivalence
3697+
// moved into `approver-address.ts`. The oracle is the pre-extraction
3698+
// predicate, verbatim; the matrix crosses both spellings with near misses and
3699+
// with position names that contain a prefix themselves.
3700+
it('resolveActor admits exactly the identities the pre-extraction predicate admitted', async () => {
3701+
const svc = svcFor(makeFakeEngine());
3702+
const preExtraction = (named: string, positions: unknown[]) =>
3703+
positions.some((position) => named === `position:${position}` || named === `role:${position}`);
3704+
const NAMED = [
3705+
'position:cfo', 'role:cfo', 'team:cfo', 'org_membership_level:cfo', 'positions:cfo', 'Position:cfo',
3706+
'cfo', 'position:', 'role:', 'position:role:cfo', 'role:position:cfo', 'position:cfo ', 'u_other',
3707+
];
3708+
const POSITION_SETS: unknown[][] = [[], ['cfo'], ['finance', 'cfo'], ['role:cfo'], ['position:cfo'], [''], [7]];
3709+
let admittedCount = 0;
3710+
for (const positions of POSITION_SETS) {
3711+
for (const named of NAMED.concat(['position:7', 'role:7'])) {
3712+
const admitted = await (svc as any).resolveActor(named, holding('u_caller', positions)).then(
3713+
(actor: string) => { expect(actor).toBe(named); return true; },
3714+
(err: Error) => { expect(err.message).toMatch(/^FORBIDDEN: cannot act as /); return false; },
3715+
);
3716+
expect(admitted, `named '${named}' with positions ${JSON.stringify(positions)}`)
3717+
.toBe(preExtraction(named, positions));
3718+
if (admitted) admittedCount++;
3719+
}
3720+
}
3721+
// Both arms of the matrix are exercised — not a sweep of refusals only.
3722+
expect(admittedCount).toBeGreaterThan(5);
3723+
});
3724+
});
3725+
35903726
// ── The ordering invariant the dead-run sweep rests on (#3456) ─────────
35913727
//
35923728
// `releaseDeadRunRequests` recalls a PENDING request whose owning run has

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

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ import {
7070
type ApproverOrgScopeDeps,
7171
type ApproverOrgScopeEngine,
7272
} from './approver-org-scope.js';
73+
// The ONE spelling equivalence for a position's slot address — read by the
74+
// acting path, the "My Pending" filter and the participant gate alike.
75+
import { equivalentApproverAddresses, positionAddresses } from './approver-address.js';
7376
import {
7477
redactSnapshot,
7578
resolveReadableSnapshotFields,
@@ -1529,10 +1532,12 @@ export class ApprovalService implements IApprovalService {
15291532
// Named something else — allow it ONLY if the server can prove the caller
15301533
// holds that identity. `positions` is resolved by the shared authz resolver
15311534
// (never client-supplied); `role:` is the ADR-0090 D3 deprecated spelling
1532-
// that 15.x-era slots and the Console's own identity list still carry.
1535+
// that 15.x-era slots and the Console's own identity list still carry. The
1536+
// spellings come from `positionAddresses` — the one equivalence the list
1537+
// filter and the participant gate read too (`approver-address.ts`).
15331538
const named = String(actorId);
15341539
for (const position of context.positions ?? []) {
1535-
if (named === `position:${position}` || named === `role:${position}`) return named;
1540+
if (positionAddresses(position).includes(named)) return named;
15361541
}
15371542
// Email last — it costs a read, so only when nothing cheaper matched.
15381543
if (named.includes('@') && await this.callerHasEmail(uid, named)) return named;
@@ -6228,17 +6233,26 @@ export class ApprovalService implements IApprovalService {
62286233
* `sys_approval_approver` index — the indexed replacement for the old
62296234
* in-memory CSV scan, and what makes approver-filtered pagination correct
62306235
* past any scan window (issue #1745). A request matches when ANY of the
6231-
* caller's identities (user id / email / role:<r>) holds a pending slot.
6232-
* Returns null when the filter is absent (callers skip the id constraint).
6236+
* caller's identities (user id / email / a position address) holds a pending
6237+
* slot. Returns null when the filter is absent (callers skip the id
6238+
* constraint).
6239+
*
6240+
* A position address matches under EVERY spelling of that position — the
6241+
* index stores the slot as it was written (`position:<p>` for a slot opened
6242+
* on an unstaffed position, `role:<p>` for a 15.x-era one), while a client
6243+
* may ask under either. The spellings come from `equivalentApproverAddresses`,
6244+
* the same equivalence `resolveActor` admits a caller under; ⛔ never a
6245+
* second fold written here.
62336246
*/
62346247
private async approverRequestIds(
62356248
targets: string[],
62366249
tenantOrg: string | null,
62376250
): Promise<string[] | null> {
62386251
if (!targets.length) return null;
6239-
const where: any = targets.length === 1
6240-
? { approver: targets[0] }
6241-
: { approver: { $in: targets } };
6252+
const addresses = [...new Set(targets.flatMap(equivalentApproverAddresses))];
6253+
const where: any = addresses.length === 1
6254+
? { approver: addresses[0] }
6255+
: { approver: { $in: addresses } };
62426256
if (tenantOrg) where.organization_id = tenantOrg;
62436257
const rows = await this.engine.find('sys_approval_approver', {
62446258
where, fields: ['request_id'],
@@ -6269,12 +6283,19 @@ export class ApprovalService implements IApprovalService {
62696283
* commenter). Admins with override authority keep the unrestricted view the
62706284
* "all requests" console surface depends on.
62716285
*
6272-
* Keying on the concrete user id is sufficient rather than an approximation:
6273-
* position/team/manager/field approvers are resolved to concrete user ids at
6274-
* open time, and the `type:value` literal is only the fallback for a spec
6275-
* that resolved to NOBODY — a slot no one can act on either way (`can_act`
6276-
* is a plain membership test over the resolved ids). So this cannot hide a
6277-
* request from someone who could actually act on it.
6286+
* "Current approver" means a pending slot the caller could ACT under, which
6287+
* is wider than their concrete user id: position/team/manager/field
6288+
* approvers are resolved to concrete user ids at open time, but a position
6289+
* that nobody held at open time leaves the literal `position:<p>` slot (and a
6290+
* 15.x-era slot reads `role:<p>`), and `resolveActor` lets a caller who holds
6291+
* `p` — server-resolved `context.positions`, never client-supplied — act
6292+
* under either spelling. Keying on the user id alone hid exactly those
6293+
* requests from the people who could decide them: absent from "My Pending"
6294+
* under every spelling the client asked for, `404` on the request itself,
6295+
* while the approve call succeeded. So the probe asks for the user id AND
6296+
* every address of every position the caller holds, from the same
6297+
* `positionAddresses` equivalence the acting path reads — no request becomes
6298+
* visible here that the caller could not decide.
62786299
*/
62796300
private async visibleRequestIds(
62806301
context: ExecutionContext,
@@ -6301,8 +6322,10 @@ export class ApprovalService implements IApprovalService {
63016322

63026323
try {
63036324
// Current approver — via the normalized index, so every identity form
6304-
// the write path recorded is covered.
6305-
for (const id of (await this.approverRequestIds([uid], tenantOrg)) ?? []) ids.add(id);
6325+
// the write path recorded is covered: the user id, and every slot
6326+
// address of a position the caller holds (see the doc block above).
6327+
const actingAddresses = [uid, ...(context.positions ?? []).flatMap(positionAddresses)];
6328+
for (const id of (await this.approverRequestIds(actingAddresses, tenantOrg)) ?? []) ids.add(id);
63066329

63076330
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
63086331
add(
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The one position-address equivalence (`approver-address.ts`).
5+
*
6+
* Three readers take it from here — `resolveActor`, the "My Pending" filter
7+
* (`approverRequestIds`) and the participant gate (`visibleRequestIds`) — so
8+
* these pins are on the equivalence itself: exactly the two spellings the
9+
* acting path has always admitted, and nothing else folds. The service-level
10+
* pins (both spellings list, the acting path unchanged) live beside the other
11+
* participant-visibility pins in `approval-service.test.ts`.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { equivalentApproverAddresses, positionAddresses } from './approver-address.js';
16+
17+
describe('approver-address — the position-address equivalence', () => {
18+
it('spells a position under exactly the two prefixes the acting path admits, canonical first', () => {
19+
expect(positionAddresses('sales_manager')).toEqual(['position:sales_manager', 'role:sales_manager']);
20+
});
21+
22+
it('folds either spelling of a position onto both', () => {
23+
const both = ['position:sales_manager', 'role:sales_manager'];
24+
expect(equivalentApproverAddresses('position:sales_manager')).toEqual(both);
25+
expect(equivalentApproverAddresses('role:sales_manager')).toEqual(both);
26+
});
27+
28+
it('splits on the FIRST accepted prefix only, so a position name may itself contain a colon', () => {
29+
expect(equivalentApproverAddresses('role:position:x')).toEqual(['position:position:x', 'role:position:x']);
30+
expect(equivalentApproverAddresses('position:role:x')).toEqual(['position:role:x', 'role:role:x']);
31+
});
32+
33+
// The negative control: a fold wider than the acting path would list
34+
// requests the caller cannot decide.
35+
it.each([
36+
['a user id', 'u_reviewer'],
37+
['an email', 'reviewer@example.com'],
38+
['a team literal', 'team:sales_manager'],
39+
['a membership-level literal', 'org_membership_level:sales_manager'],
40+
['a department literal', 'department:sales_manager'],
41+
['a near-miss prefix', 'positions:sales_manager'],
42+
['a differently-cased prefix', 'Position:sales_manager'],
43+
['a prefix not at the start', 'x-role:sales_manager'],
44+
])('treats %s as equivalent only to itself', (_label, address) => {
45+
expect(equivalentApproverAddresses(address)).toEqual([address]);
46+
});
47+
});

0 commit comments

Comments
 (0)