Repository navigation
fix(plugin-sharing)!: the ownership-queue service refuses a failed member read when the caller asks, and the approver slate and the sharing-recipient grant recompute ask - #30386
Conversation
… position or queue directory read fails; queue reads page to completion Part 1 of #29604 (seat ruling 6648946388, route A). - plugin-approvals: a failed directory read behind a team, position or queue approver (and an expression's resolveAs team/position value) throws ApproverDirectoryUnreadableError, so openNodeRequest opens on no slate under any onEmptyApprovers policy, the fallback slate included. decide's pre-snapshot re-resolution keeps the literal slot. - plugin-sharing: the queue service's authored sys_queue / sys_queue_member reads go through the shared keyset walk instead of a 10,000-row cut; a failed read keeps today's answer until part 2. Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh Co-authored-by: Claude <noreply@anthropic.com>
…ery policy and the queue reads past one page The approval node refuses to open, and writes nothing, when a team, position or queue approver's directory read fails, under admin_rescue, auto_approve, fail and fallback alike, beside a staffed person, and on a fallback slate. A department failure and decide's legacy re-resolution keep their answers. The queue reads find the 10,001st member, queue and naming row, and a read that fails on its second page answers as a failed read does today. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…prover directory, plugin-sharing pages its queue reads Also states the request row the legacy-decide pin reads as present, which the test-layer type-check ledger counts. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…tion read filter refuses the node, named as that read The ADR-0105 D9 read filter on an expression approver's team and position values ran outside the refusing try, so its failure failed the node with the raw error. It now refuses with ApproverDirectoryUnreadableError naming the filter's read, as the static resolver's filter does; one helper words that read for both. Pinned on both paths with the filter made to reject. The ownership-queue warning for a walk that could not vouch for completion logs the reason only, without the walk's "so the expansion refuses instead", since the queue still answers nobody. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…jects until part 2 The ownership-queue service does not reject a failed member read yet, so the narrowing names a queue approver whose expansion rejects, and says the service's own failed reads still answer nobody until the contract can carry the refusal. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…the node config takes it The test-layer type-check program admits no new file with errors. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…ours limit and carries no write verbs it is never sent The node under these pins never updates or deletes through the engine, so the double declares neither, and its find cuts at the caller's limit as the engine does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…n the shared approval-service harness Moved as one top-level describe into approval-service.test.ts, on the shared fake engine, with the pass-through spy on the read filter declared there; the separate file is gone. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…mber read when the caller asks, and the approver slate and the sharing-recipient recompute ask expandMembers and isMember honour the contract's onFailedRead option. Under 'refuse' a failed sys_queue, sys_queue_member, team, position, sys_member or business-unit read rejects with a failure naming the queue, its organization and the read, never [] / a partial set / false; isMember still answers true when a read that succeeded reaches the user. The default 'nobody' answers as before, so the admission consumers (access path, claim, assignment pool) are unchanged. The activation-ledger read keeps "switched on" under both. The approver slate's queue arm (D6) and the sharing-rule recompute's queue recipient (D7: whole-rule pass, recipient-axis revoke, per-user pass, record pass) pass 'refuse': the node refuses with ApproverDirectoryUnreadableError, and each recompute's caller keeps the rule's grants and warns. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…ntry stated as delivered Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…takes it Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
…ver refuses the node Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
… state in this entry what it supersedes Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6af8cf76f92d969649e297be141d21f203f8df6c && git checkout 6af8cf76f92d969649e297be141d21f203f8df6c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 870ebce333bf0a5794307febe077ab5652cfbfe9 b24881883d2b1704cded17f71731d95b609febee && git checkout -B drift-repro 870ebce333bf0a5794307febe077ab5652cfbfe9 && git merge --no-ff b24881883d2b1704cded17f71731d95b609febee
node scripts/docs-audit/affected-docs.mjs --json 870ebce333bf0a5794307febe077ab5652cfbfe9
|
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head b248818 · 2026-11-05T09:01Z |
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Contract reviewServed-tier: Rendered 2026-11-05T09:23Z. Inputs: card #29604 (body and every comment, the rulings 6594432222, 6599731698 and 6600766777, the route-A ruling 6648946388, the landing note 6652336497 and the part-2 dev report 6653951527 included), PR #30386 (body, eight-path file list, net diff against ① Derived judgments
② Semver level
Clause-②: yes (narrowing) — right for the diff: the service's Judgment (e), part 1's pending entry: its ③ Boundary flags
④ Design judgmentRead against ADR-0142 D2 / D6 / D7 as amended on Findings:
No must-fix. Implemented-by: VERDICT: PASS |
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF 席位意见:ACCEPT(#29604 第 2 部分)。调用方传 席位核对Head-sha:
|
Requested by Bill · project thread
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF
维护者速读
expandMembers/isMember在调用方传onFailedRead: 'refuse'时,队列背后任何一次读取失败(sys_queue、sys_queue_member、team、position、sys_member、BU 表)都抛错并点名那次读取,不再答「无人」。审批节点的 queue 审批人(D6)和共享规则的 queue 收件人重算(D7)都传'refuse';准入类(D3 访问、D4 认领、D5 分配池)不传,仍答「无人」。APPROVER_DIRECTORY_UNREADABLE;D7 各重算保留规则已有授权并告警,读取恢复后下一次重算收敛(有 pin)。默认'nobody'路径不变,激活台账读失败仍按「开启」。回滚即撤 PR。ownership-queue-service.ts的queueReadRefusal、specsReachUser、holderCheck与expandSpec的pass.refuse分支;sharing-rule-service.ts、approval-service.ts各一处传参。pnpm --filter @objectstack/plugin-sharing exec vitest run src/ownership-queue.test.ts src/queue-recipient.test.ts -t "onFailedRead 'refuse'|keeps the queue-recipient|29604\] control",预期 13 条全过;pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-service.test.ts -t "asks the queue service to refuse",预期 4 条全过。Fixes #29604
Clause-②: yes (narrowing) —
expandMembersandisMemberof the registered ownership-queue service, called withonFailedRead: 'refuse', now reject when a read behind the queue fails, where they answered nobody. The approver slate asks for that answer, so an approval node whosequeueapprover cannot be read refuses to open under everyonEmptyApproverspolicy, where it opened on the rest of the slate, on the fallback slate or on the administrators, or auto-approved. The sharing-rule recompute asks for it too, so a whole-rule pass (evaluateRule, the REST evaluate endpoint, the boot backfill), the recipient-axis revoke and the per-user pass on aqueue-recipient rule reject on such a failure, where they completed by revoking the grants of the members who could not be read. Every call without the option, and every read that succeeds, answers as before.Metadata-protocol: none
Skills-docs:
content/docs/automation/approvals.mdxcorrected: it still said ateamorpositionentry whose holder read failed keeps its literal slot, which part 1 (#30288) already made false; it now says a failed read behind ateam,positionorqueueentry refuses the node, and keeps the literal statement fordepartmentandorg_membership_level. No page inskills/**states what a queue answers on a failed read.What changes
Part 2 of #29604, under claim 6648819700 and seat ruling 6648946388, now that #30251 (PR #30318) put
onFailedReadon the contract. Part 1 (PR #30288,13c0c5baa) is merged.1.
OwnershipQueueServicehonoursonFailedRead(plugin-sharing/src/ownership-queue-service.ts).expandMembers(name, org, { purpose?, onFailedRead? })andisMember(user, name, org, { onFailedRead? })take the contract's option. The per-callExpansionPasscarriesrefuse, an axis of its own besidepurpose.'refuse', every read behind the queue passes its failure on instead of contributing nobody: thesys_queuerow lookup (authoredQueueRow, which now words its own failure asthe sys_queue row of queue '<name>' in organization '<org>' could not be read: …), thesys_queue_memberwalk (authoredMemberSpecs), each specification's expansion (expandSpec) and each team or position holder check (holderCheck). The paged reads already refuse on a failed page or an incomplete walk (part 1 / plugins: the rest of the sharing and approvals recipient reads still truncate at 10,000 rows or turn a failed read into "nobody" (sys_team_member, sys_user_position, holdsRole/teamHasMember, queue expandSpec) — #29362 close-out #29503), so a refusal is never an answer on the pages already read.queueReadRefusal:[queue] queue '<name>' in organization '<org>' does not answer its members, because a read behind it failed: <the read, in its own words>. The caller asked for a refusal over a partial answer (onFailedRead 'refuse'): …. The inner read loses its[sharing]tag and its own closing consequence ("…, so the expansion refuses instead."), and rides along whole ascause.isMemberunder'refuse'walks every specification: a later one that reaches the user still answerstrue, since a read that succeeded and found the user is a fact a failure beside it cannot remove. Only when none reaches does the first failure become the answer. Sofalseis answered only when every read succeeded, as the contract says. The queue's ownsys_queue/sys_queue_memberreads come first and reject before any specification is read, because without them the call does not know what to ask.'nobody', the default, nothing moves: each failed read contributes nobody, logged once.resolveQueue,queuesOf,queuesNamingandhasAnyQueuetake no option and keep that answer.switchedOff) keeps reading "switched on" when it fails, under both answers. The contract left that to this card; see Acceptance notes.holderCheck/readFailureReasoncomments no longer say the refusal waits on the contract.2. D7, the sharing-recipient grant recompute (
plugin-sharing/src/sharing-rule-service.ts). Thequeuebranch ofexpandRecipientand ofruleReachesUserpass{ onFailedRead: 'refuse' }.expandRecipientfeeds the whole-rule pass (desiredGrantsForRule), the recipient-axis revoke (revokeRuleGrantsForRetiredRecipients, which expands before it diffs) and the record pass (expandRecipientForRecord);ruleReachesUserfeeds the per-user pass (reconcileRuleForUser). In each, the refusal throws before any grant is diffed or revoked, so the rule's grants stay as they were. No new catch was needed: each production caller already isolates a refusing rule and warns, as it has forteamandpositionrecipients since #29503 / #29362 (evaluateAllForRecord,evaluateAllRulesForObject, the queue-hooks and BU-tree recomputes, the membership-row recompute, the boot backfill).3. D6, the approver slate (
plugin-approvals/src/approval-service.ts).expandQueueUserspasses{ purpose: 'addressing', onFailedRead: 'refuse' }. Part 1's catch inresolveApproverSpecalready turns aqueueexpansion's rejection intoApproverDirectoryUnreadableErrorbefore the "expanded to nobody" return, so a queue the registered service cannot read now refuses the node under everyonEmptyApproverspolicy, the fallback slate included, with the queue's failed read asfailedRead.4. Admission consumers untouched. The sitting-record access path (
SharingService→isMember/queuesOf), the claim gate (security-plugin-authz-middleware.ts→isMember),explain-access, and the assignment pool (objectql/src/assignment-rules.ts→expandMembers) call without the option and answer nobody on a failed read, as before.5. Changeset. The new
29604-queue-service-refuses-failed-read.md(BREAKING,!, Clause-② narrowing, ADR-0087not-required (no-migration-prescription)) carries this part. Its last bullet honours part 1's "Not yet": it names the two sentences of part 1's pending entry (itsClause-②:sentence that the service "does not reject yet", and its Not yet bullet) and says they no longer hold and are to be read as this bullet. Part 1's file itself is left as it stands onmain; see Acceptance notes.Tests
All new pins sit in the existing shared harnesses as top-level
describes. Each⛔case was run againstorigin/main's three source files with this branch's tests: every one is red there.ownership-queue.test.ts, "onFailedRead 'refuse' rejects a failed read, naming it (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)", 7 cases on the shared fake engine (one table's reads made to throw):sys_queueread rejectsexpandMembersandisMember, naming the row read, with no warning; the default still answers[]/false, warned once;sys_queue_memberread rejects, never the declared positions alone (default: the positions alone);sys_team_member,sys_user_positionorsys_memberread behind a specification rejects with the exact read sentence, without "refuses instead", and the driver error ascause;isMemberunder refuse:truewhen a read that succeeded reaches the user, with the failing specification before or after it; a rejection for a user only the failed read could reach, and for a non-member; the default answersfalsefor the same user;[], an unresolved name[]/false, healthy reads equal to the default;ownership-queue.test.ts, D3describe, 1 control: withsys_team_memberfailing, the access path gives a team-only member no access and throws nothing, and a position member keeps access.queue-recipient.test.ts, "a failed queue-member read keeps the queue-recipient rule's grants (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)", 5 cases through the realOwnershipQueueServiceandSharingRuleServiceover one engine (queuebilling= useru_c+ teamt_night→u_d): the whole-rule pass rejects and revokes nothing, for a failedsys_queue_memberand for a failedsys_team_member; the recipient-axis revoke rejects and retires nobody; the per-user pass rejects foru_dand still answersu_c; the record pass keeps the grants and warns per rule with the queue's refusal; once the read succeeds, the next pass converges (a member who left loses the grant). The existing case "grants each member the expander returns" now pins the third argument{ onFailedRead: 'refuse' }. The fake engine gained afailingset.approval-service.test.ts, "a queue approver asks the queue service to refuse a failed read, and the node refuses with it (ADR-0142 D6)", 4 cases, one peronEmptyApproverspolicy: a queue double that answers as the registered service does (rejects under'refuse',[]under'nobody') is asked{ purpose: 'addressing', onFailedRead: 'refuse' }, and the node refuses withApproverDirectoryUnreadableErrorwhosefailedReadis the queue's refusal, logs no "expanded to nobody" and writes no request, approver or action row. The existing "asked for ADDRESSING" case pins the new option too.Ablations, one per new refusal (
scripts/ablation-replace.mjs, wrap mode)Each ran on the committed head, each restore proven (
blob == HEAD,git diff HEADempty). Subjects are imported from source.sys_queuerow read never refusessys_queuecasesys_queue_memberread never refusessys_queue_membercase, the whole-rule passisMembercase, the per-user passisMember's first failure ends the walkisMembercase (truepast a failed position read)isMemberasks for nobodyexpandMembersasks for nobodyGates, local
Hashes are the pushed ones: the seven unpublished commits were reworded once before the first push (the pre-push trailer check), with every tree unchanged, so each run below stands on the commit named.
origin/mainmerged once (b66d9c676), then the whole workspace built (pnpm build, 119 of 119 tasks). On head33a64b2be,node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 92 families on the changed paths, and each exit code was written to a file before any pipe. 91 exited 0.check-empty-changeset --base origin/mainexited 1 on the in-place edit of part 1's pending changeset (Acceptance notes);b24881883restores that file from the merge base, and on it the derivation is unchanged (same 92) and the four changeset gates exit 0 (check-empty-changeset,check-adr-0087-registration,check-changeset-no-major,check:changeset-fixed). Reconciled on that record:Also exit 0: the four roster gates whose roster sits under a changed directory (
check:authz-resolver,check:changeset-fixed,check:error-code-casing,check:filter-alias-parity),pnpm lint, the engine-double gates (check:engine-double-contract,check:objectql-double-limit, both in the derived list), andtypecheckof both packages with the test-layer ledger (plugin-sharing's new test file errors fixed, no ledger entry added). Full suites: plugin-sharing 1405 passed (65 files), plugin-approvals 1469 passed (111 files).Dogfood battery, once, on the pushed head's tree (
b24881883)The whole workspace built, then
pnpm --filter @objectstack/dogfood testunderos-verify-lock.sh:The
permission-set-clone-doorfailure part 1 reported is gone on this base (main reverted its cause in #30262).Acceptance notes
positionrecipients (positionConfersAccess), so a queue's position and a rule's position cannot disagree during the outage, and the D6 slate reads positions on the addressing purpose, which skips the position ledger. The cost, stated: during such an outage a D7 pass can grant the holders of a position that is switched off. That predates this card and is unchanged here.isMemberunder refuse answerstruewhen a successful read reaches the user. The contract fixes whenfalsemay be answered ("only when every read behind it succeeded"); it does not say a failure beside a positive read refuses. Refusing there would make the answer depend on specification order, and on D7 it would hold back a grant the user is owed.sys_queueorsys_queue_memberread rejectsisMembereven for a declared queue whose positions might reach the user, since the call cannot know the queue's full membership without them.packages/spec/src/contracts/ownership-queue-service.ts,OwnershipQueueFailedReadAnswer: (1) "the registered one … honours'refuse'from 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's code half on, so until then a caller passing'refuse'still gets'nobody'" can drop its "until then"; (2) "whether that posture moves is 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's code half to decide" can state the decision (it does not move); (3)isMember's doc could say thattrueis answered when a read that succeeded reaches the user, even beside a failed one.Clause-②:line's "The registered ownership-queue service does not reject yet: …" and the Not yet bullet) become false when this merges, while both entries are still unreleased. I first corrected them in place, andcheck-empty-changesetrefused it as designed: rewriting a pending note on the merge base is a release decision that needs a person's confirmation, and the gate stays red until then. So this PR leaves the file as onmainand supersedes the two sentences from its own entry instead. If the maintainer prefers the note corrected in place, it is a three-line change (.changeset/29604-approver-directory-refusal-queue-paging.md) that I can push once confirmed, with that gate red by design.content/docs/automation/approvals.mdxhad kept part 1's stale sentence (ateam/positionentry whose holder read failed keeps its literal). Part 1's PR body said no page stated it; that was wrong, and this PR fixes the page.Verification
Expect 13 and 4 passed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh