Repository navigation
Commit 47226e6
feat(spec): IOwnershipQueueService lets a caller ask for a refusal on a failed member read (onFailedRead) (#30318)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBLWzy6uXpPjBQbCmtm2Mgpz)_
domain:spec#3 · os-support-ai · session_01WGufzfHeHezsXgrF5MEs4N
Fixes #30251
Part of #29604 (this PR is the contract only; the producer in
`plugin-sharing` and the D6/D7 callers that pass `'refuse'` stay with
#29604's code half, part 2)
Clause-②: yes (widening: an optional `onFailedRead` on `expandMembers`'
options and an optional options argument on `isMember`; precedent PR
#30015)
Metadata-protocol: none
Skills-docs: none — a call-time option of a kernel service contract that
no metadata author writes; `skills/**` and `content/docs/**` name the
ownership-queue service but none of its methods (`git grep -n -i
"expandMembers|IOwnershipQueueService" -- skills content/docs`: 0 hits)
Prior rulings read: onfailedread,ownership queue,expandmembers → 0 hits;
none; thread: none; repo: objectstack-ai/objectstack
## 维护者速读
- 改了什么:队列成员服务的契约(`IOwnershipQueueService`)新增一个可选开关
`onFailedRead`,调用方可以说明「成员表读失败时」要的是「当作没人」还是「直接报错」。只改类型和文档注释,不改任何运行逻辑。
- 为什么改:ADR-0142 D2
规定审批人名单(D6)和共享授权重算(D7)遇到读失败必须报错,否则一次数据库故障就会撤掉整个队列给出的授权;但审批插件只能通过这份契约调用队列,原来没有办法表达这个要求。
- 风险与回滚:默认值就是今天的行为(`'nobody'`),所有现有调用和实现原样编译、原样运行;回滚即还原本 PR。真正改行为的是
#29604 的第二部分。
- 先看:`packages/spec/src/contracts/ownership-queue-service.ts`(新类型
`OwnershipQueueFailedReadAnswer`
的注释和两个方法签名)、`ownership-queue-failed-read.pin.test.ts`。
- 两分钟验证:`pnpm --filter @objectstack/spec check:test-typecheck`,预期 OK;pin
文件里四个 `@ts-expect-error` 证明非法取值编译不过。
- 需要你做的:无,席位落地。
## What changes
- **New exported type `OwnershipQueueFailedReadAnswer = 'nobody' |
'refuse'`** (`@objectstack/spec/contracts`). Its docblock carries the
whole rule, citing ADR-0142, D2 "Membership", the amended paragraph "An
empty expansion is a fact; a failed read is a fault":
- the tables behind a queue: `sys_queue`, `sys_queue_member`,
`sys_team_member`, `sys_user_position`, `sys_member` through the
position transition limb, and the business-unit membership and tree (D2
names the BU reads too, so the list keeps them); a table the composition
does not carry is absence, not failure;
- `'nobody'`: the default and today's answer, logged at `warn`; taken by
the access path (D3), the claim admission (D4) and the assignment pool
(D5);
- `'refuse'`: a failed read rejects with a failure that names the read,
never `[]`, a partial set or `false`; taken by the approver slate (D6)
and the sharing-recipient grant recompute (D7);
- under either answer an empty queue is still `[]`, and a name that
resolves nowhere is still `[]` / `false`;
- ⛔ independent of `QueueExpansionPurpose` (contract review 6600699486,
carried in 6600766777).
- **`expandMembers(name, organizationId, options?: { purpose?;
onFailedRead? })`**: the new key sits beside `purpose`; the method
docblock points at the type.
- **`isMember(userId, name, organizationId, options?: { onFailedRead?
})`**: a new optional trailing argument, so every three-argument call
compiles unchanged. Under `'refuse'`, `false` is answered only when
every read behind it succeeded.
- **Module header**: one sentence, "A read that FAILS is not absence",
pointing at the new type.
- **Generated**: `api-surface/contracts.json` and
`export-origins/contracts.json` gain the one new type (`check:generated
--fix`, rebuilt dist).
- **Changeset**: `@objectstack/spec` `patch` (`Metadata-protocol: none`
grades every entry `patch`).
## Caller census
`git grep -n "IOwnershipQueueService\|expandMembers(\|isMember("` at
`959c26be`, every hit outside generated JSON and the contract itself:
| site | what it uses | effect of the widening |
|---|---|---|
| `plugin-sharing/src/ownership-queue-service.ts:127`
`OwnershipQueueService implements IOwnershipQueueService` | the one
implementer; `expandMembers(…, options?: { purpose? })`,
`isMember(userId, name, org)` | still assignable (method parameter
bivariance; an implementation may take fewer trailing params).
Type-checked, see Tests. Behaviour unchanged: it ignores the key until
#29604 part 2 |
| `plugin-sharing/src/sharing-rule-service.ts:432, :1747, :2186` (D7) |
`Pick` of 'expandMembers' \| 'isMember', calls without options |
compiles unchanged; these are the call sites #29604 part 2 moves to
`'refuse'` |
| `plugin-sharing/src/sharing-service.ts:557, :952` (D3) | `Pick` of
'queuesOf' \| 'isMember', 3-arg `isMember` | unchanged, default
`'nobody'` |
| `plugin-sharing/src/index.ts:177` | type re-export | unchanged |
| `plugin-approvals/src/approval-service.ts:1638, :3347` (D6) | `Pick`
of 'expandMembers', `{ purpose: 'addressing' }` | compiles unchanged;
#29604 part 2 adds `onFailedRead: 'refuse'` here |
| `objectql/src/assignment-rules.ts:74, :293` (D5) | `Pick` of
'resolveQueue' \| 'expandMembers', 2-arg call | unchanged, default
`'nobody'` |
| `plugin-security/src/ownership-queue-gate.ts:32`,
`security-plugin.ts:2670`, `security-plugin-authz-middleware.ts:1602`
(D4), `security-plugin-explain-access.ts:253, :512` | `Pick` of
'resolveQueue' \| 'isMember', 3-arg `isMember` | unchanged |
| `plugin-security/src/kernel-queue-membership.ts:44`,
`security-plugin.ts:2683` | `Pick` of 'queuesOf' | member untouched |
| `plugin-hono-server/src/current-user-endpoints.ts:317` | `Pick` of
'hasAnyQueue' | member untouched |
| `runtime/src/domains/catalog-activation.ts:166` | `Pick` of
'declaredQueueNamed' | member untouched |
| tests: `plugin-sharing/src/ownership-queue.test.ts`,
`plugin-security/src/kernel-queue-membership.test.ts:113` | calls
without options | compile unchanged |
No caller breaks: the key is optional on an options bag that was already
optional, and the `isMember` argument is a new optional trailing one.
## Tests
- New
`packages/spec/src/contracts/ownership-queue-failed-read.pin.test.ts`,
compiled by `check:test-typecheck` under `tsconfig.test.json`:
- type-level `Assert`/`Eq` pins: the answer type is exactly `'nobody' |
'refuse'`; both members' `onFailedRead` read it; `expandMembers`' option
keys are exactly `purpose | onFailedRead` and `isMember`'s exactly
`onFailedRead`; `purpose` keeps its type; the two value sets are
disjoint;
- an implementation written before the key (`expandMembers` with `{
purpose? }` only, a 3-param `isMember`) is still an
`IOwnershipQueueService`;
- call shapes that compile: no options, `{}`, `{ purpose }`, `{
onFailedRead: 'refuse' }`, both keys, the 3-arg and 4-arg `isMember`;
- four `@ts-expect-error` shapes that must not compile: `onFailedRead:
'throw'`, `'empty'`, `'addressing'`, and `purpose` on `isMember`;
- one runtime case, so vitest counts the file.
<details><summary>Reverse verification (two mutations, each restored by
scripts/ablation-replace.mjs to the HEAD blob)</summary>
- Mutation 1: delete `isMember`'s `options?: { onFailedRead?:
OwnershipQueueFailedReadAnswer },` → `check:test-typecheck` red,
`ownership-queue-failed-read.pin.test.ts: 4 type error(s)`; restored,
blob `2bd06d9682c3` == HEAD, `git diff HEAD` empty.
- Mutation 2: widen the type to `'nobody' | 'refuse' | 'throw'` → red,
`2 type error(s)` (the `Eq` pin and the now-unused `@ts-expect-error`);
restored, blob == HEAD.
Both read through `tsconfig.test.json`, which resolves the contract from
`src`, so no dist rebuild was needed for the legs.
</details>
Local runs, all on head `12fb7d30`, each under `os-verify-lock` with
`NODE_OPTIONS=--max-old-space-size=4096`:
- `pnpm --filter @objectstack/spec typecheck` (tsc, scripts,
`check:test-typecheck`) and the new pin file: VERDICT command-exit 0;
`Test Files 1 passed (1)`.
- `pnpm --filter @objectstack/spec test`: VERDICT command-exit 0; `Test
Files 813 passed (813)`, `Tests 25576 passed | 1 todo (25577)`.
- Downstream (the contract's import side, `^...` closure built first, 36
turbo tasks): `typecheck` of `@objectstack/plugin-sharing` (the
implementer), `@objectstack/plugin-approvals`,
`@objectstack/plugin-security` and `@objectstack/objectql`: VERDICT
command-exit 0, each `check:test-typecheck: OK`. `plugin-hono-server`
and `runtime` pick only `hasAnyQueue` / `declaredQueueNamed`, members
this PR does not touch, so they were not re-typechecked (CI's full
typecheck covers them).
- `check:generated --fix` rebuilt `packages/spec/dist` and regenerated
`api-surface/` and `export-origins/` (the two artifacts it proved
stale); a later plain `check:generated` is green.
## Acceptance notes
- The activation-ledger read (`switchedOff`, answered "on" when its read
fails) is not named in the new docblock: contract review 6600766777
leaves that posture for #29604's code half to decide and say.
- `queuesOf` gets no `onFailedRead`: no ADR-0142 recompute consumer
reads it, and the card does not ask for it.
## Verification
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 80 families at `12fb7d30`; all 80
ran, every one exit 0. `--ran` reconciliation: `80 derived famil(ies)
accounted for — 80 run, 0 NOT-MEASURED`.
- Three first-pass non-zero exits, each a local prerequisite and re-run
green on the same head:
- `check-plugin-teardown-shape.mjs --self-test` exit 3: shallow clone
missing the pinned fixture commit; fetched it, then exit 0 (48 cases
pass);
- `spec check:generated` exit 1: the runner had no `NODE_OPTIONS`, and
`check:test-typecheck`'s tsc ran out of the default 2 GB heap; re-run
with 4 GB, exit 0;
- `check:dual-build-cjs-loads` exit 3 (no dist for 38 packages); after a
full `turbo run build` (120 tasks), exit 0: 116 require entry points
across 71 packages load.
- `check-changeset-no-major.mjs --base origin/main` exit 0 with the
`@objectstack/spec` `patch` entry under `Metadata-protocol: none`.
- Repo-wide lint and `test:repo` are CI's runs.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(a TypeScript contract type only)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes. (repo-wide lint is CI's
run)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (the maintainer brief
is in Chinese by repo rule)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzfHeHezsXgrF5MEs4N
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0837fdc commit 47226e6
5 files changed
Lines changed: 165 additions & 3 deletions
File tree
- .changeset
- packages/spec
- api-surface
- export-origins
- src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
241 | 241 | | |
242 | 242 | | |
243 | 243 | | |
| 244 | + | |
244 | 245 | | |
245 | 246 | | |
246 | 247 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
241 | 241 | | |
242 | 242 | | |
243 | 243 | | |
| 244 | + | |
244 | 245 | | |
245 | 246 | | |
246 | 247 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
38 | 41 | | |
39 | 42 | | |
40 | 43 | | |
| |||
45 | 48 | | |
46 | 49 | | |
47 | 50 | | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
48 | 92 | | |
49 | 93 | | |
50 | 94 | | |
| |||
92 | 136 | | |
93 | 137 | | |
94 | 138 | | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
95 | 145 | | |
96 | 146 | | |
97 | 147 | | |
98 | 148 | | |
99 | | - | |
| 149 | + | |
100 | 150 | | |
101 | 151 | | |
102 | | - | |
103 | | - | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
104 | 168 | | |
105 | 169 | | |
106 | 170 | | |
| |||
0 commit comments