Repository navigation
fix(plugin-approvals)!: an approval node refuses to open when a team or position directory read fails or a queue expansion rejects, and the ownership-queue reads page to completion - #30288
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
📓 Docs Drift Check16 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 5a47c49ef795781562a3ff3b012f5b573aa56354 && git checkout 5a47c49ef795781562a3ff3b012f5b573aa56354
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bfca0efb79ac5df2049f392cdecd3bbeeea9ec61 1a3854d288f1e5e2bac081863ee5281783bfe012 && git checkout -B drift-repro bfca0efb79ac5df2049f392cdecd3bbeeea9ec61 && git merge --no-ff 1a3854d288f1e5e2bac081863ee5281783bfe012
node scripts/docs-audit/affected-docs.mjs --json bfca0efb79ac5df2049f392cdecd3bbeeea9ec61 |
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head 98d62fd · 2026-11-05T05:14Z |
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Contract reviewServed-tier: Rendered 2026-11-05T05:37Z. Inputs: card #29604 (body and all eleven comments, the rulings 6594432222, 6599731698, 6600766777 and the seat's route-A ruling 6648946388 included), PR #30288 (body, eight-path file list, net diff against ① Derived judgments
② Semver level
Clause-②: yes (narrowing) — right for the diff: the approval node's accept set narrows at open, and nothing on a published entry widens. ③ Boundary flags
④ Design judgmentRead against ADR-0142 D2 / D6 / D7 as amended on Findings:
No must-fix. Implemented-by: VERDICT: PASS |
…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
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head c4c64fd · 2026-11-05T06:04Z |
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Contract reviewServed-tier: Rendered 2026-11-05T06:26Z — a delta review on the four commits above the head my record 6650659075 passed ( ① Derived judgments
② Semver levelUnchanged: Clause-②: yes (narrowing) — right for the diff; the delta narrows nothing further on a published surface and widens nothing (the expression filter case was already a node failure, now under the class). ③ Boundary flags
④ Design judgmentThe delta tightens the design my first record passed without moving any ruled answer: the one class now covers the cross-organization filter on both resolvers, the published text now claims exactly what ships for Findings:
No must-fix. Implemented-by: VERDICT: PASS |
…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
|
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF 席位意见:ACCEPT(#29604 第 1 部分)。审批节点上 team、position 审批人的名录读取(含跨组织读过滤)失败,或 queue 审批人展开时报错,节点不再开单,改报可重试的 席位核对Head-sha:
|
Requested by Bill · project thread
domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF
维护者速读
APPROVER_DIRECTORY_UNREADABLE,点名是哪次读取失败。队列成员与队列列表改为按 id 分页读完,不再在第 10,000 行截断。approval-service.ts的ApproverDirectoryUnreadableError与resolveApproverSpeccatch 里的一处 throw;ownership-queue-service.ts三处readMembershipToCompletion。pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-service.test.ts -t "refuses the node",预期 18 条全过;pnpm --filter @objectstack/plugin-sharing exec vitest run src/ownership-queue.test.ts -t "page to completion",预期 5 条全过。Refs #29604 (part 1)
Clause-②: yes (narrowing) — an approval node no longer opens when a
teamorpositionapprover (static, or anexpressionapprover withresolveAsteam/position) cannot be expanded because a directory read or the cross-organization read filter failed, or when aqueueapprover's expansion rejects. Under everyonEmptyApproverspolicy, thefallbackApproversslate included, it now fails the node with a retryableAPPROVER_DIRECTORY_UNREADABLE:error naming the read, where it used to open on the rest of the slate, on the fallback slate or on the administrators, or auto-approve. The registered ownership-queue service does not reject yet: a failed member read inside it still answers nobody until #29604 part 2 (#30251), so aqueueapprover refuses here only whenexpandMembersitself rejects. Every other input answers as before. Nothing is widened: the new error class is not on the package entry.Metadata-protocol: none
Skills-docs: none. No page in
skills/**orcontent/docs/**states what an approval node does when a directory read fails, so no text moves.What changes
This is part 1 of #29604, as the seat ruled (route A, comment 6648946388): the parts that do not wait on the spec contract. The queue's own refusal on D6 and D7 is part 2, behind the spec-lane card #30251, and will carry
Fixes #29604.1. The approval node refuses to open when a team or position directory read fails, or a queue approver's expansion rejects (ruling 6599731698, letter B; ADR-0142 D6 as amended).
ApproverDirectoryUnreadableError(plugin-approvals, not on the package entry) carriesapproverType,approverValue,failedReadandretryable: true, and the original error ascause. Its message startsAPPROVER_DIRECTORY_UNREADABLE:(the message-prefix family this service already uses forNO_APPROVERS:), names the approver, the organization and the read, and says that the node is not opened on the rest of the slate, the fallback slate or the administrators, and is not auto-approved. It never saysNO_APPROVERS.resolveApproverSpec, the catch that recordsdirectoryFailurenow throws that error first when the approver type isteam,positionorqueue. Because the throw sits in the catch, it runs before thequeuebranch's "expanded to nobody" return, which used to answer an unreadable queue as an empty one. That is the reorder the ruling asks for. On this head it fires for a queue only whenexpandMembersrejects: the registeredOwnershipQueueServicestill answers its own failed member reads with nobody until part 2, so a queue backed by it adds no slot on such a failure, as before.resolveExpressionApproversthrows the same error forresolveAs: 'team'and'position', since those values are read by the same expanders. Its ADR-0105 D9 filter call now sits in its own try and refuses the same way, naming the filter's read; any other failure there fails the node as it always did.fallbackApproversslate whose own team or position read fails refuses too, rather than degrading toadmin_rescue.sys_memberlimb (the transition read) is a failed read like any other, as the carried item says (pinned on a realsys_member).{ success: false }with theruntimefailure class, which is retryable.approval-node.tsis not touched.expandApproverstakesonDirectoryFailure: 'refuse' | 'literal'(default'refuse'). OnlydecideNode's pre-snapshot re-resolution passes'literal': it runs for requests opened before the approver snapshot existed, after the decision's action row is written, so a refusal there would fail a decision that is already recorded. It keeps the literal slot it always kept.bounded) sits inside its try, so its failure is now rethrown asthe cross-organization read filter (…) could not be read: …, and the warning and the refusal name that read rather than the directory's (carried item). One helper words that read for the static and theexpressionresolver.filterApproversWhoCanReaditself still answers its own failedsys_memberread by keeping the slate (ADR-0105 D9), so in practice this naming is reached only when the filter throws past its own catch.2. The ownership-queue reads page to completion (the 10,000-row reading, 6591670782).
queuesNaming,authoredQueueNamesandauthoredMemberSpecsreadsys_queue/sys_queue_memberthroughreadMembershipToCompletion(team-graph.ts, the keyset walk the team and position reads already use since #29503), where each read one page ofQUEUE_READ_LIMIT = 10000rows and answered on it.QUEUE_READ_LIMITis gone. What a failed read answers is unchanged until part 2: the catch keeps its warn-once and contributes nobody. The warning carries the read's reason only: the driver's error, or for a walk that could not vouch for completion its sentence without the walk's own "so the expansion refuses instead", which is not this service's answer yet. A walk that fails on a later page now answers like a failed read, never with the pages already read.authoredQueueRowis a one-row lookup by name and stays as it is.Tests
approval-service.test.ts, new top-leveldescribe"a team, position or queue approver whose directory read fails refuses the node (ADR-0142 D6)", 14 cases on the shared fake engine with one table's reads made to throw:sys_team_member) and position (sys_user_position) ×admin_rescue,auto_approve,fail,fallback: rejects withApproverDirectoryUnreadableError(approverType,approverValue,retryable: true, message prefix,failedReadnames the driver error, neverNO_APPROVERS), and nosys_approval_request,sys_approval_approverorsys_approval_actionrow is written. 8 cases.userapprover, the node still refuses.expandMembersrejects refuses, and the "expanded to nobody" warning is not logged.departmentread still opens the node (not named by the ruling).decideNodeon a pre-snapshot request still decides while the team read fails.'literal'reader keeping the old slot and warning:team-position-holders-complete(team, position),member-tier-holders-complete(position whosesys_memberlimb fails;org_membership_levelkeeps its literal),business-unit-holders-complete(expressionwithresolveAs: 'team').approval-service.test.ts, a second new top-leveldescribe"a failed cross-organization read filter refuses the node, named as that read (ADR-0142 D6)", 4 cases on the shared fake engine and the cross-org scenario ofapprover-cross-org.integration.test.ts.filterApproversWhoCanReadis wrapped by a pass-through spy declared at the top of the harness (vi.mockof./approver-org-scope.js, every other case runs the real filter through it): a staticpositionapprover and anexpressionapprover resolving asposition, each targeted at the group withorganization: '$root'. Each reaches the group CFO when the filter answers (control), and refuses withApproverDirectoryUnreadableErrorwhosefailedReadis exactly the filter's sentence when the filter rejects, writing no request.ownership-queue.test.ts, new top-leveldescribe"the queue reads page to completion (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. Four at 10,001 rows: a queue's last member is expanded, is a member, and lists the queue inqueuesOf; an organization's 10,001st queue is listed;queuesNamingfinds the queue naming the recipient on row 10,001; a read failing on its second page answers[]/falsewith one warning carrying the page's error. The fifth: a full page whose last row has no id answers[], and its one warning carries the reason without the walk's "refuses instead" clause. The test double learned$gtandorderBy, which the keyset walk sends. The existing fixture rows gained ids, which a keyset walk needs.Ablations, one per new refusal (
scripts/ablation-replace.mjs, wrap mode, underos-verify-lock.sh)Every ablated test imports its subject by relative source path, so no build sits between the mutation and the run. Each restore was proven (
blob == HEAD,git diff HEADempty).queuequeueresolveAs: 'team'on driver-sql'literal'removedA1 to A6 ran on
98d62fd6c; B1 to B3 onc18905922, the commit that added them. B1 and B2 ran again on1a3854d28, after the pins moved into the shared harness: each still reds exactly its one case (1 of 459).Gates, local, on head
c4c64fdee(origin/mainmerged once, at98d62fd6c)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 62 families on the changed paths. The whole workspace was built first (turbo run build --filter='!@objectstack/docs', 119 of 119 tasks), and each exit code was written to a file before any pipe, then reconciled:All 62 exited 0. Also exit 0: the four artifact-roster gates the derivation marks as keeping their roster under a changed directory (
check:authz-resolver,check:changeset-fixed,check:error-code-casing,check:filter-alias-parity),pnpm lint,pnpm --filter @objectstack/plugin-approvals --filter @objectstack/plugin-sharing typecheck(the test-layer ledger included), and both packages' full suites (plugin-sharing 1392 passed). The same 62 also exited 0 on98d62fd6c.On
1a3854d28(the cross-org pins moved into the shared harness, a test-only change): plugin-approvals 1465 passed, its typecheck (test-layer ledger included) exit 0,node scripts/check-engine-double-contract.mjsexit 0,pnpm check:objectql-double-limitexit 0,pnpm lintexit 0.Dogfood battery, once, on head
98d62fd6c(the title carries!and the declaration a narrowing arm)Not re-run after the review rounds: since
98d62fd6cthe source changes are theexpressionresolver's filter catch and the queue warning's text; the rest is tests and the changeset.The whole workspace built, then
pnpm --filter @objectstack/dogfood testunderos-verify-lock.sh:The one failure is not this change's.
test/permission-set-clone-door.dogfood.test.ts› "a platform set (the lock-and-clone rule's commonest source) copies into its shipping package's pair" expects200from cloningmember_defaultand receives422 INVALID_METADATA: the clone grants onsys_commentandsys_activity, which the permission validator says no package registers. It fails the same way alone (1 failed | 10 passed), and fails the same way with both of this PR's source files (approval-service.ts,ownership-queue-service.ts) restored toorigin/main771b2db2and both packages rebuilt (the builtdistthen carries 0 occurrences ofApproverDirectoryUnreadableErrorand 4 ofQUEUE_READ_LIMIT). Neither package touches permission sets or object registration. Reported to the seat.Acceptance notes
[]/falseand logs once), the D7 grant recompute keeping grants, and the D6 slate refusing on a queue whose member table fails inside the service. All three need the call-time signal spec: IOwnershipQueueService lets the caller ask for a refusal on a failed member read (onFailedRead), which ADR-0142 D2 as amended needs for the D6 and D7 consumers #30251 adds to the contract. Part 1's queue refusal fires only whenexpandMembersitself rejects.department/business_unit/bu,org_membership_levelandmanager(andresolveAs: 'department') still fall back as before on a failed read, with the plugins: sharingexpandRoleand approvals role-tier recipients read sys_member at a 10,000-row bound and turn a failed read into an empty list, so a large or unreadable membership silently shrinks the set #29362 warning naming the failure. Pinned fordepartmentandorg_membership_level.switchedOffkeeps answering "switched on" whensys_metadata_activationcannot be read. On the D6 slate and the D7 grant pass that errs toward keeping people, never toward dropping them, so it cannot shrink a slate or revoke a grant. On the admission paths it can let a switched-off position confer access during that outage; that posture predates this card and is not changed here.codefield. The refusal travels as a message prefix and a class, asNO_APPROVERS:does. Acodewould entercheck:dispatcher-error-vocabulary, whose table lives inpackages/runtime, outside the claim.expressionresolver's cross-organization filter refuses as the static one does, pinned on both; the queue warning no longer quotes the walk's "refuses instead". Commitsc18905922,34e14c1ef,50deed3e8, andc4c64fdeefor the engine double ESLint flagged (it declaredupdate/deletethe node never sends and ignoredlimit).Clause-②:line and summary use the changeset's wording, and the cross-org pins live in the sharedapproval-service.test.tsharness (1a3854d28).Verification
Expect 18 and 5 passed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh