Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions .changeset/30251-ownership-queue-on-failed-read.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/spec/api-surface/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@
"OWNERSHIP_QUEUE_SERVICE (const)",
"ObjectDraft (interface)",
"OwnershipFloorAlternate (interface)",
"OwnershipQueueFailedReadAnswer (type)",
"PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)",
"PendingActionRow (interface)",
"PendingActionStatus (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
/** Compile error when the argument is not `true`. */
type Assert<T extends true> = T;

type ExpandOptions = NonNullable<Parameters<IOwnershipQueueService['expandMembers']>[2]>;
type IsMemberOptions = NonNullable<Parameters<IOwnershipQueueService['isMember']>[3]>;

export type _AnswerIsExactlyTwoValues = Assert<Eq<OwnershipQueueFailedReadAnswer, 'nobody' | 'refuse'>>;
export type _ExpandReadsTheAnswerType = Assert<Eq<ExpandOptions['onFailedRead'], OwnershipQueueFailedReadAnswer | undefined>>;
export type _IsMemberReadsTheAnswerType = Assert<Eq<IsMemberOptions['onFailedRead'], OwnershipQueueFailedReadAnswer | undefined>>;
export type _PurposeUnchanged = Assert<Eq<ExpandOptions['purpose'], QueueExpansionPurpose | undefined>>;
export type _ExpandOptionKeys = Assert<Eq<keyof ExpandOptions, 'purpose' | 'onFailedRead'>>;
export type _IsMemberOptionKeys = Assert<Eq<keyof IsMemberOptions, 'onFailedRead'>>;
/** The axes stay apart: no failed-read answer is a purpose, and no purpose is a failed-read answer. */
export type _AxesDisjoint = Assert<Eq<Extract<OwnershipQueueFailedReadAnswer, QueueExpansionPurpose>, 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<unknown[]> {
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, []]);
});
});
70 changes: 67 additions & 3 deletions packages/spec/src/contracts/ownership-queue-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<string[]>;

/** 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<boolean>;
/**
* 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<boolean>;

/**
* The machine names of every queue `userId` is a member of in
Expand Down
Loading