Skip to content

feat(spec): IOwnershipQueueService lets a caller ask for a refusal on a failed member read (onFailedRead) - #30318

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ooozse
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ooozse

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

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。真正改行为的是 plugins: a queue sharing recipient still revokes grants on a member-table outage, because the queue expander answers "nobody" for every consumer; refuse for the sharing-grant pass and the approver slate (ADR-0142 amendment first) #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.
Reverse verification (two mutations, each restored by scripts/ablation-replace.mjs to the HEAD blob)
  • 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.

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

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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (a TypeScript contract type only)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (repo-wide lint is CI's run)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (the maintainer brief is in Chinese by repo rule)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzfHeHezsXgrF5MEs4N

… a failed member read (onFailedRead)

ADR-0142 D2 (as amended) says each queue consumer declares at call time
whether a failed member read answers nobody or refuses. expandMembers gains
an optional onFailedRead beside purpose, isMember gains an optional trailing
options argument with the same key, both typed OwnershipQueueFailedReadAnswer
(nobody | refuse, default nobody). Contract only; the producer and the D6/D7
callers land with #29604.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfHeHezsXgrF5MEs4N
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 170 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c1240a38ac2c23c8d526e476a20edeabbbefd6f6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4e0943281d97d0993a2f89f8a35ad5f6f978674c — the merge of head 41db2e116df54c05dfb38be4cfc0a0d5d58a5a24 into base c1240a38ac2c23c8d526e476a20edeabbbefd6f6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4e0943281d97d0993a2f89f8a35ad5f6f978674c && git checkout 4e0943281d97d0993a2f89f8a35ad5f6f978674c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c1240a38ac2c23c8d526e476a20edeabbbefd6f6 41db2e116df54c05dfb38be4cfc0a0d5d58a5a24 && git checkout -B drift-repro c1240a38ac2c23c8d526e476a20edeabbbefd6f6 && git merge --no-ff 41db2e116df54c05dfb38be4cfc0a0d5d58a5a24

node scripts/docs-audit/affected-docs.mjs --json c1240a38ac2c23c8d526e476a20edeabbbefd6f6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head 12fb7d3 · 2026-11-05T05:59Z

Full review of the first head at CONTRACT_REVIEW_TIER (a widening of a published contract: onFailedRead on IOwnershipQueueService).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 12fb7d30196dbf2dff1ab4858157efc266b30005
Local-runs: none
Scope: packages/spec/src/contracts/ownership-queue-service.ts, packages/spec/src/contracts/ownership-queue-failed-read.pin.test.ts, packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json, .changeset/30251-ownership-queue-on-failed-read.md (the whole diff against merge-base 959c26be, one commit, +158/−3); at origin/main: docs/adr/0142 (status line, D2 as amended, D6 and D7 notes), packages/plugins/plugin-sharing/src/ownership-queue-service.ts (the one implementer), ownership-queue.test.ts, position-graph.ts, business-unit-graph.ts, packages/spec/tsconfig.test.json, package.json, test-typecheck-debt.json, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, .github/workflows/lint.yml; card #30251 (body, comments 6649330330, 6649914605, 6649969637, 6650955285), #29604 comments 6648911579 and 6600766777, contract review 6600699486 on PR #29865, PR #30318 body and comments 6650956931, 6650966285; the 41 check-runs on the head.

① Derived judgments

  • Pure widening, verified at the type level and by census. expandMembers keeps options? and adds one optional key (ownership-queue-service.ts:139-143); isMember gains a fourth, optional, trailing argument (:155-160). Every caller at origin/main reaches the service through a Pick and calls with no options, {}-equivalent or { purpose: 'addressing' } (approval-service.ts:3347, assignment-rules.ts:293, sharing-rule-service.ts:1747, :2186, sharing-service.ts:952, security-plugin-authz-middleware.ts:1602, security-plugin-explain-access.ts:512, kernel-queue-membership.test.ts:113, ownership-queue.test.ts); the PR's census table matches my grep hit for hit, and the remaining hits use only OWNERSHIP_QUEUE_SERVICE or hasAnyQueue/queuesOf/declaredQueueNamed, members the diff does not touch. The one implementer (plugin-sharing/src/ownership-queue-service.ts:171-186) declares { purpose? } and a three-parameter isMember; it stays assignable (fewer trailing parameters, and { purpose?; onFailedRead? } is assignable to { purpose? }), which the pin's legacy object proves at compile time (pin.test.ts:52-57). Omitting the key is today's answer: the default is 'nobody' and nothing at runtime changed.
  • The silent-ignore case is real and the docblock does not name it. The implementer at main consults neither key: expandMembers builds newPass(options?.purpose) only (:178) and isMember takes no options (:181), so a caller passing { onFailedRead: 'refuse' } against the registered service today gets 'nobody', logged once at warn (expandSpec :309-314, holderCheck :394-403, warnOnce :541). The contract's 'refuse' bullet (:69-73) states the rejection as a promise with no clause on who honours it or from when; only the PR body, the changeset and the module header's "What the expander answers then is the caller's to say" carry the split. Route A (6648911579) ruled the split and the card says no runtime change, so this is not a false claim about the design, but the published docblock is the text a third-package caller reads — should-fix 1.
  • The pin is not a phantom: tsconfig.test.json includes src/**/*, check:test-typecheck is in spec's typecheck script (package.json:320, :323), which Type Check · workspace runs through turbo run typecheck (lint.yml:5665, :5681); the new file has no debt entry, so zero errors is the ratchet. No shared harness exists for this contract in spec (queue-service.test.ts is the JOB queue's IQueueService), so a new file is right under ruling 6583487846.
  • Docblock against D2 as amended: both answers, the D3/D4/D5-nobody and D6/D7-refuse split, "logged at warn", a refusal that names the read and is never []/partial/false, an empty queue and a name that resolves nowhere still []/false — each matches D2's text and the D6/D7 notes. The table list is true of the code at main: sys_queue (authoredQueueRow), sys_queue_member (authoredMemberSpecs), sys_team_member (TeamGraphService), sys_user_position plus sys_member through expandRoleUsers/holdsRole (position-graph.ts:119, :167, the limb 6600766777 asked the code half to treat as a failed read), and the BU membership and tree (BusinessUnitGraphService, sys_business_unit_member and sys_business_unit, business-unit-graph.ts:46-47), reached through expandSpecUncached :331-341. D2 names sys_business_unit_member and the BU tree itself, so the dev's addition beyond the card's list is D2's, not an invention. Nothing in the 'nobody' bullet is future work; the 'refuse' bullet is the future-work gap above.
  • Check-runs on 12fb7d30: 41; at reading time 15 success, 8 skipped by path filter, 18 in progress, 0 failure. Governed Surface Queue Guard, PR-body guards, Check Changeset, Check PR Size, Lint · PM dispatch-gates, Spec property liveness and Dogfood Regression Gate are success; the Type Check lanes (the one that compiles the pin) and Test Core shards were still running. No governed path in the file list.

② Semver level

  • @objectstack/spec: patch with Metadata-protocol: none: check-changeset-no-major.mjs grades every entry under none as patch (header lines 12-13, 909-910), and the level lost its coupling to Clause-② (901-918), so patch on a widening of a published type is the reading, not a downgrade. Check Changeset is success on the head.
  • Clause-②: yes (widening: …): readArmToken matches (widening followed by a non-word character, so the colon reads as the widening arm, not a near miss (clause2-line.mjs:311-313). Metadata-protocol: none is right — no key, value or metadata type an author writes moves. ADR-0087 is not needed: nothing is removed, renamed, aliased or tombstoned.

③ Boundary flags

④ Design judgment

The signal is the one D2 says the consumer sends, spelled once as an exported value type both methods read, trailing and optional on both, and kept off the purpose axis in the type, the docblock (:79-81) and the pin (_AxesDisjoint, the 'addressing' and purpose-on-isMember refusals) — exactly what review 6600699486 required and route A chose. The pins would fail without the change: the type import, the Eq on isMember's fourth parameter and the { onFailedRead: 'refuse' } calls do not compile against main's contract. Body matches code: Fixes #30251, Part of #29604, the census, Prior rulings read: and the 维护者速读 are present and accurate; the generated api-surface and export-origins carry the one new export and nothing else moves (no ADR anchor names this file, no skills/** or content/docs/** page names any method).

Implemented-by: claude/project-thread-ooozse
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: PASS

… the code half, and where the activation-ledger read stands

Contract review should-fixes: the 'refuse' bullet says an implementation
written before the key answers 'nobody' and plugin-sharing honours 'refuse'
from the code half on; the table list names sys_business_unit_member and
sys_business_unit and says the sys_metadata_activation read is not a failed
read in this sense (its failure still reads switched on).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfHeHezsXgrF5MEs4N
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

维护者速读

席位意见:fable 契约复审 PASS,没有必须修的。两条注释准确性建议已推送采纳,席位亲自核对了差异。

席位核对 — ACCEPT · domain:spec#3 · head 41db2e116df54c05dfb38be4cfc0a0d5d58a5a24

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant