diff --git a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md index b37d62d13ce..0b2d2ecaf24 100644 --- a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md +++ b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md @@ -1,6 +1,6 @@ # ADR-0142: An ownership queue is a platform primitive — a queue entity, membership through the existing recipient kinds, claim through the ownership-transfer gate, and declarative assignment rules evaluated on insert -**Status**: Proposed (2026-10-25) · **Amended** (2026-10-28, ruling [`6457475657`](https://github.com/objectstack-ai/objectstack/issues/25769#issuecomment-6457475657) on #25769, letter A) — D3's `current_user.queues` producer is `@objectstack/plugin-security`, kernel-side, under the reserved key `queues`; see the note in D3. `docs/adr/**` is Tier H (Prime Directive #14): this record becomes **Accepted** by the +**Status**: Proposed (2026-10-25) · **Amended** (2026-10-28, ruling [`6457475657`](https://github.com/objectstack-ai/objectstack/issues/25769#issuecomment-6457475657) on #25769, letter A) — D3's `current_user.queues` producer is `@objectstack/plugin-security`, kernel-side, under the reserved key `queues`; see the note in D3 · **Amended** (2026-11-03, ruling records [`6594432222`](https://github.com/objectstack-ai/objectstack/issues/29604#issuecomment-6594432222) and [`6599731698`](https://github.com/objectstack-ai/objectstack/issues/29604#issuecomment-6599731698) on #29604, letter B in both) — D2 tells an empty expansion (a fact) from a failed read behind the queue (a fault): the admission consumers (D3, D4, D5) keep "nobody" on a failed read, the D7 grant recompute refuses it, and the D6 approver slate refuses it with the approval node refusing to open; see the notes in D2, D6 and D7. `docs/adr/**` is Tier H (Prime Directive #14): this record becomes **Accepted** by the maintainer's merge, and ⛔ the one PR under [Execution plan](#execution-plan-after-acceptance) is cut after that merge, never before it. Until a published `@objectstack/*` release carries that PR, HotCRM keeps the STOPGAP module this record exists to retire. **This record declares; it implements nothing** — no schema, no code, no changeset. @@ -308,6 +308,31 @@ admission (D4), the assignment pool (D5), the approver slate (D6), the sharing r of the queue service. The approvals engine's `queue` branch calls it rather than growing a third position expander. An empty expansion is a fact, not a fault: the consumers below each say what they do with it. +**An empty expansion is a fact; a failed read is a fault; the one expander answers the two apart** (**Amended** +2026-11-03, ruling record [`6594432222`](https://github.com/objectstack-ai/objectstack/issues/29604#issuecomment-6594432222) +on #29604, letter B). An expansion is *empty* when every member specification was read and reached nobody — an unstaffed +position, an emptied team, a queue with no member rows, a name that resolves nowhere (D1) — and the consumers below each +say what they do with it. A read *fails* when any read the expansion makes cannot be read at all — the queue's own row +(`sys_queue`, through which an authored queue resolves), the member rows (`sys_queue_member`), and the member tables a +specification reads (`sys_user_position`, `sys_team_member`, `sys_business_unit_member`, the BU tree): the expander then +knows nothing about who is in the queue, and "nobody" would be an invented answer. A queue that could not be resolved is +not a queue that resolves nowhere: the first is a failed read, the second is absence. A table that does not exist in the +composition is absence, not failure, as the sibling recipient reads already hold it. Who is owed which answer follows +from what the consumer does with the set, and the consumer says which it is owed when it calls (how the call says so is +the implementing PR's): + +- the **admission consumers** — the access path (D3), the claim admission (D4) and the assignment pool (D5) — keep + **"nobody"** for a failed read, logged at `warn`, because for them nobody *denies*: a sitting record is not reached + through the queue for that request, a member's insert naming the queue and a claim resting on D3's basis alone are + refused as a non-member's are, and a row parks in the queue (D5 says so already). Nothing is granted and nothing is + revoked on an answer the expander could not stand behind; +- the **recompute consumers** — the approver slate (D6) and the sharing-recipient grant pass (D7) — **refuse** a failed + read: they turn the member set into routed work or materialised grants, so "nobody" there revokes every grant the queue + gave and routes a request past the people who could not be read — the posture every other sharing and approvals + recipient read gave up when the team, position and role reads were made to read to completion and refuse a failed read. + A refusal is a thrown failure naming the read, never `[]`, `null` or `false`; what each refusing consumer does with it + is written under D6 and D7. + **Membership moves, and three things follow it.** A write to `sys_queue_member`, and a write to any membership table a queue's members read (`sys_user_position`, `sys_team_member`, `sys_business_unit_member`, the BU tree), changes who is in the queue. The access path needs no recompute (it is resolved per request); the sharing grants that name a queue are @@ -503,8 +528,9 @@ remove gate; Prime Directive #10): ADR-0105 D9), and the queue service expands it. 2. `resolveApproverSpec` gains the queue branch: the slate is the queue's expansion; an empty expansion warns as the graph types do ("expanded to nobody") and then returns `[]` as the person types do — a new combination, because the - graph path writes the `type:value` literal after its warning and this branch must not. The `queue:` literal slot - is never written again; a slot a stored request already holds stays as stored, decidable by the override, exactly as + graph path writes the `type:value` literal after its warning and this branch must not. The `queue:` literal slot + (the queue's machine name, D1, as the resolver writes an approver's `value`) is never written again; a slot a stored + request already holds stays as stored, decidable by the override, exactly as ADR-0090 D3's `role:` slots are. The node's `onEmptyApprovers` decides the empty slate. 3. The spec drops `queue` from `NON_AUTHORABLE_APPROVER_TYPES` and so from `xEnumDeprecated`; `APPROVER_VALUE_BINDINGS.queue` becomes a real binding — a kind the designer contract does not have today, "a catalog item **or** an organization row, @@ -517,6 +543,23 @@ remove gate; Prime Directive #10): 5. A test drives a stored `queue` approver through the real service to a slate of the queue's members, and the cross-organization integration suite gains the queue case. +**Amended** (2026-11-03, ruling records [`6594432222`](https://github.com/objectstack-ai/objectstack/issues/29604#issuecomment-6594432222) and +[`6599731698`](https://github.com/objectstack-ai/objectstack/issues/29604#issuecomment-6599731698) on #29604, letter B in +both) — item 2's "expanded to nobody" is the *empty* case of D2 as amended. A **failed** read behind the queue (D2) +**refuses** the slate, and the approval node then **refuses to open**, under every `onEmptyApprovers` policy alike +(`admin_rescue`, `auto_approve`, `fail`, `fallback`): the request is not routed to the organization's administrators, not +auto-approved and not opened on a fallback slate. The node fails loudly with an error class of its own — not `NO_APPROVERS`, +not "expanded to nobody" — that names the read that failed, and the failure is retryable: the run is re-triggered once the +directory reads again. No parked or auto-retry state is part of this decision; one that is wanted is its own card. The +ruling puts a `team` or `position` approver whose directory read failed in the same class, so the one node refusal covers +the three; it replaces the earlier wording of record 6594432222, under which a refused slate followed the approvals +resolver's existing failure class. The execution point: the directory-failure catch of +`packages/plugins/plugin-approvals/src/approval-service.ts#resolveApproverSpec` already records the failure +(`directoryFailure`); the `queue` branch's early "expanded to nobody" return must consult it first, and +`packages/plugins/plugin-approvals/src/approval-service.ts#openNodeRequest` must see it and refuse before +`packages/plugins/plugin-approvals/src/approver-address.ts#slateNamesAPerson` hands the slate to `onEmptyApprovers`. Item +2's "never written again" stays whole: a failed read writes no `queue:` literal and no slot of any kind. + What it resolves to at runtime: the queue's members at node entry, in the request's organization. Re-admission is not an ADR-0087 conversion: the enum member never left, no stored shape changes, and the designer annotation is not a protocol surface. The generated reference page re-renders from the enum's description. @@ -548,6 +591,21 @@ The recipient becomes authorable in the same PR when **all** of the following ho `sharing-rules` row's summary (its note demands the re-transcription) and the authorable-surface snapshot; the migration-17 entry keeps its text as the record of protocol 17's state. +**Amended** (2026-11-03, ruling record [`6594432222`](https://github.com/objectstack-ai/objectstack/issues/29604#issuecomment-6594432222) +on #29604, letter B) — item 1's "an empty queue grants nothing and warns once" is the *empty* case of D2 as amended. A +**failed** read behind the queue (D2) **refuses** the grant pass instead of answering nobody, and every pass that +materialises or revokes a queue recipient's grants takes the refusal in the isolation shape the other recipient kinds +already have (#29362, #29503): the rule-wide passes (`evaluateRule`, the per-record `evaluateAllForRecord` and the +recipient-axis `revokeRuleGrantsForRetiredRecipients` in `packages/plugins/plugin-sharing/src/sharing-rule-service.ts`) and +the per-user recompute (`reconcileRuleForUser` in the same file, reached from +`packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts` through the queue's holder check) each stop **that +rule's** pass — or that rule-and-user pair's — before any revoke, keep the rule's existing grants exactly as they were, log +the failure at `warn` naming the rule and the read, and let the record's other rules reconcile. A grant the queue gave is +never revoked because its members could not be read; it is re-evaluated on the rule's next pass (a write to the rule, the +next membership write, a restart). The look-through that finds the rules a membership write moves (the "membership +moves" paragraph of D2; `packages/plugins/plugin-sharing/src/ownership-queue-service.ts#queuesNaming`) revokes nothing +and is outside this amendment. + What it resolves to at runtime: grants materialised per member, rule-wide, as `team` and `position` are today — and re-materialised when the queue's membership moves. ⛔ The recipient does not confer queue-sitting access; that is D3's path. A rule naming a queue shares *its criteria's records* with *the queue's people*, which is a different sentence.