diff --git a/.changeset/30251-ownership-queue-on-failed-read.md b/.changeset/30251-ownership-queue-on-failed-read.md new file mode 100644 index 00000000000..f1fab8c91ef --- /dev/null +++ b/.changeset/30251-ownership-queue-on-failed-read.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +feat(spec): `IOwnershipQueueService` lets a caller ask for a refusal on a failed member read (`onFailedRead`), the call-time signal ADR-0142 D2 asks each consumer to send + +Clause-②: yes (widening: an optional `onFailedRead` on `expandMembers`' options and an optional options argument on `isMember`; precedent PR #30015) +Metadata-protocol: none + +- **New type `OwnershipQueueFailedReadAnswer`** (`'nobody' | 'refuse'`, `@objectstack/spec/contracts`): what a membership read answers when a read behind the queue fails (`sys_queue`, `sys_queue_member`, `sys_team_member`, `sys_user_position`, `sys_member` through the position transition limb, the business-unit membership and tree). +- **`expandMembers(name, organizationId, { purpose?, onFailedRead? })`** and **`isMember(userId, name, organizationId, { onFailedRead? })`**. `'nobody'` is the default and the answer every caller had before: a failed read is logged at `warn` and contributes nobody. Under `'refuse'` a failed read rejects with a failure that names the read, never `[]`, a partial set or `false`; an empty queue is still `[]`. The docblock names who takes which: the access path (D3), the claim admission (D4) and the assignment pool (D5) take `'nobody'`; the approver slate (D6) and the sharing-recipient grant recompute (D7) take `'refuse'`. The key is independent of `purpose`. +- **Contract only.** Every existing call and implementation compiles unchanged and behaves as today; the producer in `@objectstack/plugin-sharing` and the D6/D7 callers that pass `'refuse'` land with #29604. diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index a51e4d4a294..8c64b5d9c1d 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -241,6 +241,7 @@ "OWNERSHIP_QUEUE_SERVICE (const)", "ObjectDraft (interface)", "OwnershipFloorAlternate (interface)", + "OwnershipQueueFailedReadAnswer (type)", "PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)", "PendingActionRow (interface)", "PendingActionStatus (type)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 2c3310765ca..01f0dbbada6 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -241,6 +241,7 @@ "OWNERSHIP_QUEUE_SERVICE": "src/contracts/ownership-queue-service.ts#OWNERSHIP_QUEUE_SERVICE (const)", "ObjectDraft": "src/contracts/external-datasource-service.ts#ObjectDraft (interface)", "OwnershipFloorAlternate": "src/contracts/security-service.ts#OwnershipFloorAlternate (interface)", + "OwnershipQueueFailedReadAnswer": "src/contracts/ownership-queue-service.ts#OwnershipQueueFailedReadAnswer (type)", "PRINCIPAL_SESSION_MAX_TTL_SECONDS": "src/contracts/auth-service.ts#PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)", "PendingActionRow": "src/contracts/ai-service.ts#PendingActionRow (interface)", "PendingActionStatus": "src/contracts/ai-service.ts#PendingActionStatus (type)", diff --git a/packages/spec/src/contracts/ownership-queue-failed-read.pin.test.ts b/packages/spec/src/contracts/ownership-queue-failed-read.pin.test.ts new file mode 100644 index 00000000000..878a0e9dbdd --- /dev/null +++ b/packages/spec/src/contracts/ownership-queue-failed-read.pin.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30251] `onFailedRead` on {@link IOwnershipQueueService}: the call-time + * signal ADR-0142 D2 (as amended) asks each consumer to send, so a recompute + * consumer reached only through this contract (the D6 approver slate in + * `@objectstack/plugin-approvals`) can ask for a refusal on a failed read. + * + * Pinned at the type level, under `check:test-typecheck` (this file compiles + * under `tsconfig.test.json`): + * + * 1. **A widening, not a narrowing.** A call with no options, and the + * three-argument `isMember` call, compile exactly as before; so does an + * implementation written before the key existed. + * 2. **Both members read one value type.** `expandMembers`' and `isMember`'s + * `onFailedRead` are both {@link OwnershipQueueFailedReadAnswer}, which is + * exactly `'nobody' | 'refuse'`; an unknown value does not compile. + * 3. **Independent of `purpose`.** The two keys sit side by side; neither + * value set leaks into the other. + * + * ⛔ Not pinned: what any implementation DOES under `'refuse'` — that is the + * producer half (#29604, `@objectstack/plugin-sharing`). + */ + +import { describe, it, expect } from 'vitest'; + +import type { + IOwnershipQueueService, + OwnershipQueueFailedReadAnswer, + QueueExpansionPurpose, +} from './ownership-queue-service'; + +/** Type-level identity: true iff A and B are the same type. */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +/** Compile error when the argument is not `true`. */ +type Assert = T; + +type ExpandOptions = NonNullable[2]>; +type IsMemberOptions = NonNullable[3]>; + +export type _AnswerIsExactlyTwoValues = Assert>; +export type _ExpandReadsTheAnswerType = Assert>; +export type _IsMemberReadsTheAnswerType = Assert>; +export type _PurposeUnchanged = Assert>; +export type _ExpandOptionKeys = Assert>; +export type _IsMemberOptionKeys = Assert>; +/** The axes stay apart: no failed-read answer is a purpose, and no purpose is a failed-read answer. */ +export type _AxesDisjoint = Assert, never>>; + +/** An implementation written before the key existed still satisfies the contract (method bivariance on an optional trailing argument). */ +const legacy: IOwnershipQueueService = { + resolveQueue: async () => null, + expandMembers: async (_name: string, _org: string | null, _options?: { purpose?: QueueExpansionPurpose }) => [], + isMember: async (_user: string, _name: string, _org: string | null) => false, + queuesOf: async () => [], +}; + +/** Every call shape the card's acceptance names; the unknown values must NOT compile. */ +async function callShapes(svc: IOwnershipQueueService): Promise { + return [ + await svc.expandMembers('q', 'org_a'), + await svc.expandMembers('q', null, {}), + await svc.expandMembers('q', 'org_a', { purpose: 'addressing' }), + await svc.expandMembers('q', 'org_a', { onFailedRead: 'refuse' }), + await svc.expandMembers('q', 'org_a', { purpose: 'addressing', onFailedRead: 'refuse' }), + await svc.expandMembers('q', 'org_a', { onFailedRead: 'nobody' }), + await svc.isMember('u', 'q', 'org_a'), + await svc.isMember('u', 'q', 'org_a', { onFailedRead: 'refuse' }), + // @ts-expect-error — not a failed-read answer + await svc.expandMembers('q', 'org_a', { onFailedRead: 'throw' }), + // @ts-expect-error — not a failed-read answer + await svc.isMember('u', 'q', 'org_a', { onFailedRead: 'empty' }), + // @ts-expect-error — `purpose` is the other axis, and `isMember` does not take it + await svc.isMember('u', 'q', 'org_a', { purpose: 'addressing' }), + // @ts-expect-error — a purpose value is not a failed-read answer + await svc.expandMembers('q', 'org_a', { onFailedRead: 'addressing' }), + ]; +} + +describe('IOwnershipQueueService onFailedRead (#30251)', () => { + it('every call shape compiles against an implementation written before the key, and answers as it did', async () => { + expect(await callShapes(legacy)).toEqual([[], [], [], [], [], [], false, false, [], false, false, []]); + }); +}); diff --git a/packages/spec/src/contracts/ownership-queue-service.ts b/packages/spec/src/contracts/ownership-queue-service.ts index 9561fa195a8..aa3f13980d6 100644 --- a/packages/spec/src/contracts/ownership-queue-service.ts +++ b/packages/spec/src/contracts/ownership-queue-service.ts @@ -35,6 +35,9 @@ * answers `[]`. Each consumer says what it does with that (ADR-0142 D5, D6, * D7) — and every queue-dependent ADMISSION fails closed when the service is * not registered at all. + * + * A read that FAILS is not absence. What the expander answers then is the + * caller's to say, per call — see {@link OwnershipQueueFailedReadAnswer}. */ import type { QueueMemberType } from '../security/queue.zod.js'; @@ -45,6 +48,47 @@ export const OWNERSHIP_QUEUE_SERVICE = 'ownership-queue'; /** Which reader an expansion is for — see {@link IOwnershipQueueService.expandMembers}. */ export type QueueExpansionPurpose = 'access' | 'addressing'; +/** + * What a membership read answers when a read behind the queue FAILS — the + * `onFailedRead` option of {@link IOwnershipQueueService.expandMembers} and + * {@link IOwnershipQueueService.isMember} (ADR-0142, D2 "Membership", the + * amended paragraph "An empty expansion is a fact; a failed read is a fault"). + * + * The tables behind a queue are its own row (`sys_queue`, through which an + * authored queue resolves), its member rows (`sys_queue_member`), and the + * member tables a specification reads: `sys_team_member`, `sys_user_position`, + * `sys_member` (through the position transition limb), `sys_business_unit_member` + * and the business-unit tree (`sys_business_unit`). A table the composition + * does not carry is absence, not failure. The activation-ledger read + * (`sys_metadata_activation`, which says whether a declared queue or a position + * is switched off) is NOT a failed read in this sense: under either answer its + * failure reads "switched on", as it did before the key existed; whether that + * posture moves is #29604's code half to decide. + * + * - `'nobody'` — the default and the answer every caller had before the key + * existed. A failed read is logged at `warn`, and what sat behind it + * contributes nobody. The admission consumers take it, because for them + * nobody denies: the sitting-record access path (D3), the claim admission + * (D4) and the assignment pool (D5). + * - `'refuse'` — a failed read rejects the call with a failure that names the + * read, never `[]`, a partial set or `false`. The recompute consumers take + * it, because they turn the member set into routed work or materialised + * grants and "nobody" there revokes: the approver slate (D6) and the + * sharing-recipient grant recompute (D7). An implementation written before + * this key existed ignores it and answers `'nobody'`; the registered one, + * `@objectstack/plugin-sharing`'s, honours `'refuse'` from #29604's code half + * on, so until then a caller passing `'refuse'` still gets `'nobody'`. + * + * Under either answer an EMPTY queue — every read succeeded and reached + * nobody — is still `[]`, and a name that resolves nowhere is still `[]` / + * `false`: those are facts, not faults. + * + * ⛔ An axis independent of {@link QueueExpansionPurpose}: which holders an + * answer keeps and whether a failed read refuses are two questions, and one + * value never answers both. + */ +export type OwnershipQueueFailedReadAnswer = 'nobody' | 'refuse'; + /** One member specification: a recipient kind and the value that kind takes in a sharing rule. */ export interface QueueMemberSpec { type: QueueMemberType; @@ -92,15 +136,35 @@ export interface IOwnershipQueueService { * off — the sitting-record path, the claim, the assignment pool and the * sharing recipient all confer access. `'addressing'` keeps them — the * approver slate routes work, and dropping holders there is fail-open. + * + * `onFailedRead` says what the call is owed when a read behind the queue + * fails: `'nobody'`, the default, or `'refuse'`, under which the call + * rejects with a failure naming the read. An empty queue is still `[]`. + * See {@link OwnershipQueueFailedReadAnswer} for the tables and for which + * ADR-0142 consumer takes which answer; it is independent of `purpose`. */ expandMembers( name: string, organizationId: string | null, - options?: { purpose?: QueueExpansionPurpose }, + options?: { purpose?: QueueExpansionPurpose; onFailedRead?: OwnershipQueueFailedReadAnswer }, ): Promise; - /** Is `userId` a member of the queue named `name` in `organizationId`? `false` when the name resolves nowhere. */ - isMember(userId: string, name: string, organizationId: string | null): Promise; + /** + * Is `userId` a member of the queue named `name` in `organizationId`? + * `false` when the name resolves nowhere. + * + * `onFailedRead` is {@link expandMembers}' key with the same meaning: + * under `'nobody'`, the default, a failed read counts as not reaching + * `userId`; under `'refuse'`, `false` is answered only when every read + * behind it succeeded, and a failed read rejects with a failure naming it. + * The options argument is optional, so a three-argument call is today's. + */ + isMember( + userId: string, + name: string, + organizationId: string | null, + options?: { onFailedRead?: OwnershipQueueFailedReadAnswer }, + ): Promise; /** * The machine names of every queue `userId` is a member of in