Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
596ee7a
fix(plugin-approvals)!: an approval node refuses to open when a team,…
claude Nov 5, 2026
7e2469f
test(plugin-approvals, plugin-sharing): pin the node refusal under ev…
claude Nov 5, 2026
8a6c373
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 5, 2026
98d62fd
chore(changeset): plugin-approvals refuses a node on an unreadable ap…
claude Nov 5, 2026
c189059
fix(plugin-approvals): an expression approver's failed cross-organiza…
claude Nov 5, 2026
34e14c1
chore(changeset): a queue approver refuses only when its expansion re…
claude Nov 5, 2026
50deed3
test(plugin-approvals): type the cross-org refusal pins' approver as …
claude Nov 5, 2026
c4c64fd
test(plugin-approvals): the cross-org refusal pins' engine double hon…
claude Nov 5, 2026
1a3854d
test(plugin-approvals): the cross-org read-filter refusal pins live i…
claude Nov 5, 2026
4a7f82f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 5, 2026
c46708b
fix(plugin-sharing)!: the ownership-queue service refuses a failed me…
claude Nov 5, 2026
8617bd9
test(plugin-sharing): name the convergence case for what it pins
claude Nov 5, 2026
38e1282
chore(changeset): the queue service's refusal, and the first part's e…
claude Nov 5, 2026
fd12db8
test(plugin-sharing): type the recompute pins' logger as the service …
claude Nov 5, 2026
b66d9c6
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 5, 2026
33a64b2
docs(approvals): a failed read behind a team, position or queue appro…
claude Nov 5, 2026
b248818
chore(changeset): leave the first part's pending note as written, and…
claude Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/29604-queue-service-refuses-failed-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-sharing': patch
'@objectstack/plugin-approvals': patch
---

fix(plugin-sharing)!: the ownership-queue service refuses a failed member read when the caller asks, and the approver slate and the sharing-recipient grant recompute ask

Clause-②: yes (narrowing) — `expandMembers` and `isMember` of the registered ownership-queue service, called with `onFailedRead: 'refuse'`, now reject when a read behind the queue fails, where they answered nobody. The approver slate asks for that answer, so an approval node whose `queue` approver cannot be read refuses to open under every `onEmptyApprovers` policy, where it opened on the rest of the slate, on the fallback slate or on the administrators, or auto-approved. The sharing-rule recompute asks for it too, so a whole-rule pass (`evaluateRule`, the REST evaluate endpoint, the boot backfill), the recipient-axis revoke and the per-user pass on a `queue`-recipient rule reject on such a failure, where they completed by revoking the grants of the members who could not be read. Every call without the option, and every read that succeeds, answers as before.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: the ownership-queue contract's `onFailedRead` option already exists, and the approval node config and the sharing rule keep their shapes. What moves is the answer two consumers get while a membership table cannot be read, and nothing at rest records that answer, so there is nothing for a migration to rewrite. -->

**BREAKING** accept-set narrowing at the ownership-queue service's `'refuse'` answer and at its two recompute consumers (`@objectstack/plugin-sharing`, `@objectstack/plugin-approvals`): an empty expansion is a fact, a failed read is a fault (ADR-0142 D2 as amended), and the approver slate (D6) and the sharing-recipient grant recompute (D7) now get the fault instead of "nobody".

- **The service.** `OwnershipQueueService.expandMembers(name, org, { onFailedRead: 'refuse' })` and `isMember(user, name, org, { onFailedRead: 'refuse' })` reject when the queue's `sys_queue` row, its `sys_queue_member` rows, or a member table one of its specifications reads (`sys_team_member`, `sys_user_position`, `sys_member`, `sys_business_unit_member`, `sys_business_unit`) cannot be read, or a paged read cannot vouch for completion. The rejection's message starts `[queue] queue '<name>' in organization '<org>' does not answer its members, because a read behind it failed:` and names that read; the read's own error rides along as `cause`. It never answers `[]`, a partial set or `false` instead. `isMember` still answers `true` when a read that succeeded reaches the user, and answers `false` only when every read behind it succeeded. An empty queue is still `[]`, and a name that resolves nowhere is still `[]` / `false`.
- **Unchanged: the default.** Without the option (`onFailedRead: 'nobody'`), a failed read still contributes nobody, logged once. The sitting-record access path (`queuesOf`), the claim admission and the assignment pool take that answer, so they deny on a failed read exactly as before. A failed read of the activation ledger (`sys_metadata_activation`) is not a failed read in this sense under either answer: it still reads "switched on" for that call, logged, the posture the sharing-rule service takes for its own position recipients.
- **The approver slate (D6).** A `queue` approver asks with `onFailedRead: 'refuse'` beside the addressing purpose. When the registered service rejects, the node fails with the retryable `APPROVER_DIRECTORY_UNREADABLE:` error that a failed team or position read already raises, naming the queue's failed read, and writes no request, approver or action row. Before, the registered service answered nobody, so the queue added no slot and the node's `onEmptyApprovers` policy acted on a slate missing those people.
- **The sharing-recipient grant recompute (D7).** A `queue`-recipient rule's expansion and its per-user membership check ask with `onFailedRead: 'refuse'`. A failed member read now rejects before any grant is diffed, so the rule's existing grants stay as they were until a pass can read the queue, and each recompute's caller logs it: the record pass and the object pass per rule (`record reconcile failed for rule`, `object reconcile failed for rule`), the queue-membership and business-unit write recomputes (`queue-membership revocation failed`, `queue-membership re-grant failed`, `business-unit revocation failed`, `business-unit re-grant failed`), the membership-row recompute (`recipient membership re-evaluation failed`) and the boot backfill (`boot rule backfill failed for rule`). A direct call to `evaluateRule`, `revokeRuleGrantsForRetiredRecipients` or `reconcileRuleForUser`, the REST evaluate endpoint included, rejects with the service's failure. Before, the failed read answered nobody and the pass revoked the grant of every member behind it. This is the answer a `team` or `position` recipient has given since its own reads began to refuse.
- **Supersedes the approval node's "Not yet".** The approval node's own entry in this release ("an approval node refuses to open when a team or position approver's directory read fails…") says, in its `Clause-②:` line and its **Not yet** bullet, that the registered ownership-queue service answers a failed member read with nobody rather than rejecting, so a `queue` approver backed by it refuses only when `expandMembers` itself rejects. That was true when it was written and is not after this change: the service now rejects on that failure when asked, and the slate asks, so a `queue` approver backed by it refuses on a failed `sys_queue`, `sys_queue_member`, team, position or business-unit read, as a `team` or `position` approver does. Read this bullet in place of those two sentences.
32 changes: 23 additions & 9 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -455,15 +455,29 @@ What an entry that resolves to nobody leaves on the slate depends on its kind:
decide it (the admin-override callout under
[Acting on requests in the console](#acting-on-requests-in-the-console)).
Beside entries that do resolve, a literal is a slot like any other, so a
`behavior` that needs every slot (`unanimous`) waits on it too. A `team`,
`position`, `department` or `org_membership_level` entry whose holder read
failed (`sys_team_member`, `sys_user_position`, `sys_business_unit_member`
or `sys_member`, and for a `department` also the `sys_business_unit` read of
the unit and of its child units) keeps its literal the same way. It logs `could not be
expanded — a directory or read-filter lookup failed` with the `error`,
instead of `expanded to nobody`, and never routes to the holders that
happened to read. An `expression` value whose `resolveAs` read failed keeps
its literal and logs the same line, with the `expression` source.
`behavior` that needs every slot (`unanimous`) waits on it too. A
`department` or `org_membership_level` entry whose holder read failed
(`sys_business_unit_member` or `sys_member`, and for a `department` also the
`sys_business_unit` read of the unit and of its child units) keeps its
literal the same way. It logs `could not be expanded — a directory or
read-filter lookup failed` with the `error`, instead of `expanded to
nobody`, and never routes to the holders that happened to read. An
`expression` value whose `resolveAs: 'department'` read failed keeps its
literal and logs the same line, with the `expression` source.
- **A failed read behind a `team`, `position` or `queue` entry** is not an
empty slate, and the node does not open (ADR-0142 D6). That is a failed
`sys_team_member` or `sys_user_position` read, the `sys_member` read behind a
position, or, for a queue, a failed read of its `sys_queue` row, its
`sys_queue_member` rows or a member table one of its members reads. An
`expression` value resolved as `team` or `position`, and a cross-organization
read filter that fails in front of any of these entries, count the same.
Under every `onEmptyApprovers` value, the `fallbackApprovers` slate included,
the node fails with a retryable error starting `APPROVER_DIRECTORY_UNREADABLE:`
that names the approver and the read, and writes no request. It is not
opened on the rest of the slate, on the fallback or on the administrators,
and it is not auto-approved. Run the flow again once the directory can be
read. A decision on a request opened before approver snapshots existed still
re-reads its slate the old way, which keeps the literal.
- **A slate left empty, or holding only literals,** goes to the node's
`onEmptyApprovers` policy, whose four values are listed under
[Approving across organizations](#cross-org-approvers). Under the default,
Expand Down
58 changes: 57 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4176,7 +4176,7 @@ describe('ApprovalService — a queue approver routes to the queue\'s members (A
const req = await svc.openNodeRequest(queueInput('billing'), CTX) as any;

expect(req.pending_approvers).toEqual(['u7', 'u8']);
expect(asked).toEqual([['billing', 't1', { purpose: 'addressing' }]]);
expect(asked).toEqual([['billing', 't1', { purpose: 'addressing', onFailedRead: 'refuse' }]]);
expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
// A member decides it under their own id.
const decided = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u8' }, SYS);
Expand Down Expand Up @@ -4349,6 +4349,62 @@ describe('ApprovalService — a team, position or queue approver whose directory
});
});

// [ADR-0142 D2 as amended, D6; #29604] The queue arm asks the ownership-queue
// service for its refusal (`onFailedRead: 'refuse'`), beside the ADDRESSING
// purpose. The registered service (`@objectstack/plugin-sharing`) answers a
// failed member read with nobody unless asked, so before the arm asked, a
// queue whose team or position read failed added no slot and the node's
// `onEmptyApprovers` policy opened, rescued or approved on a slate missing its
// people. The double below gives the contract's two answers as that service
// does: a rejection naming the read under `'refuse'`, `[]` under `'nobody'`.
describe('ApprovalService — a queue approver asks the queue service to refuse a failed read, and the node refuses with it (ADR-0142 D6)', () => {
const POLICIES = ['admin_rescue', 'auto_approve', 'fail', 'fallback'] as const;
const READ = "[queue] queue 'billing' in organization 't1' does not answer its members, because a read behind it failed: "
+ "the members of team 't_night' could not be read: SQLITE_IOERR: disk I/O error.";

const withFailingQueue = () => {
const engine = makeFakeEngine();
const asked: unknown[] = [];
const warnings: any[] = [];
let n = 0;
const svc = new ApprovalService({
engine,
clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
ownershipQueues: () => ({
expandMembers: async (name: string, org: string | null, options?: { purpose?: string; onFailedRead?: string }) => {
asked.push([name, org, options]);
if (options?.onFailedRead === 'refuse') throw new Error(READ);
return [];
},
}),
});
return { engine, svc, asked, warnings };
};
const node = (configExtra: Record<string, any>) => ({
...openInput([]),
config: { approvers: [{ type: 'queue' as const, value: 'billing' }], behavior: 'first_response' as const, lockRecord: false, ...configExtra },
});

for (const p of POLICIES) {
it(`a queue whose member read fails: onEmptyApprovers '${p}' opens nothing and approves nothing`, async () => {
const { engine, svc, asked, warnings } = withFailingQueue();
const refusal = await svc.openNodeRequest(node(
p === 'fallback' ? { onEmptyApprovers: p, fallbackApprovers: [{ type: 'user', value: 'u_backstop' }] } : { onEmptyApprovers: p },
), CTX).then(() => null, (e: unknown) => e);

expect(asked).toEqual([['billing', 't1', { purpose: 'addressing', onFailedRead: 'refuse' }]]);
expect(refusal).toBeInstanceOf(ApproverDirectoryUnreadableError);
expect(refusal).toMatchObject({ approverType: 'queue', approverValue: 'billing', retryable: true, failedRead: READ });
expect((refusal as Error).message).toMatch(/^APPROVER_DIRECTORY_UNREADABLE: approver 'queue:billing' could not be expanded in organization 't1'/);
expect(warnings.some(([msg]) => String(msg).includes("approver 'queue:billing' expanded to nobody"))).toBe(false);
expect(engine._tables['sys_approval_request'] ?? []).toEqual([]);
expect(engine._tables['sys_approval_approver'] ?? []).toEqual([]);
expect(engine._tables['sys_approval_action'] ?? []).toEqual([]);
});
}
});

// [ADR-0142 D6 as amended; ruling record 6599731698 on #29604, letter B] The
// ADR-0105 D9 cross-organization read filter is a read behind a `position` or
// `team` approver as much as the directory expansion is. When it fails, the
Expand Down
8 changes: 7 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3482,12 +3482,18 @@ export class ApprovalService implements IApprovalService {
* fail-open). The service resolves the name registry-first, then the
* organization's own `sys_queue` row, and answers `[]` for a name that
* resolves nowhere. With no service registered there is nothing to expand.
*
* [#29604] `onFailedRead: 'refuse'` (D2 as amended): a failed read behind the
* queue rejects instead of answering "nobody", and {@link resolveApproverSpec}'s
* catch turns that rejection into the node's {@link ApproverDirectoryUnreadableError},
* so the slate is never opened without the people who could not be read. An
* axis of its own beside `purpose`.
*/
private async expandQueueUsers(queueName: string, organizationId?: string | null): Promise<string[]> {
if (!queueName) return [];
const queues = this.queueSource();
if (!queues) return [];
return queues.expandMembers(queueName, organizationId ?? null, { purpose: 'addressing' });
return queues.expandMembers(queueName, organizationId ?? null, { purpose: 'addressing', onFailedRead: 'refuse' });
}

/**
Expand Down
Loading
Loading