Skip to content

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

Merged
objectstack-fleet[bot] merged 17 commits into
mainfrom
claude/project-thread-1wujme
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 17 commits into
mainfrom
claude/project-thread-1wujme

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 分配池)不传,仍答「无人」。
  • 为什么改: ADR-0142 D2 修订:空展开是事实,读取失败是故障。以前成员表一故障,D7 就把这些成员的授权全撤了,D6 节点照样开给剩下的人、fallback、管理员或自动通过。第 1 部分(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)留下的 "Not yet" 在这里兑现。
  • 风险与回滚: 只有读取失败时多一次拒绝:D6 节点报可重试的 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 条全过。
  • 需要你做的: 无,席位合同审查后落地。spec 文档措辞有 3 处 follow-up owed(见 Acceptance notes),本 PR 不改 spec。

Fixes #29604

Clause-②: yes (narrowing) — expandMembers and isMember of the registered ownership-queue service, called with onFailedRead: '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 whose queue approver cannot be read refuses to open under every onEmptyApprovers policy, 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 a queue-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.mdx corrected: it still said a team or position entry whose holder read failed keeps its literal slot, which part 1 (#30288) already made false; it now says a failed read behind a team, position or queue entry refuses the node, and keeps the literal statement for department and org_membership_level. No page in skills/** 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 onFailedRead on the contract. Part 1 (PR #30288, 13c0c5baa) is merged.

1. OwnershipQueueService honours onFailedRead (plugin-sharing/src/ownership-queue-service.ts).

  • expandMembers(name, org, { purpose?, onFailedRead? }) and isMember(user, name, org, { onFailedRead? }) take the contract's option. The per-call ExpansionPass carries refuse, an axis of its own beside purpose.
  • Under 'refuse', every read behind the queue passes its failure on instead of contributing nobody: the sys_queue row lookup (authoredQueueRow, which now words its own failure as the sys_queue row of queue '<name>' in organization '<org>' could not be read: …), the sys_queue_member walk (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.
  • The public call wraps the failure once, in 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 as cause.
  • isMember under 'refuse' walks every specification: a later one that reaches the user still answers true, 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. So false is answered only when every read succeeded, as the contract says. The queue's own sys_queue / sys_queue_member reads come first and reject before any specification is read, because without them the call does not know what to ask.
  • Under 'nobody', the default, nothing moves: each failed read contributes nobody, logged once. resolveQueue, queuesOf, queuesNaming and hasAnyQueue take no option and keep that answer.
  • The activation-ledger read (switchedOff) keeps reading "switched on" when it fails, under both answers. The contract left that to this card; see Acceptance notes.
  • The module header and the holderCheck / readFailureReason comments no longer say the refusal waits on the contract.

2. D7, the sharing-recipient grant recompute (plugin-sharing/src/sharing-rule-service.ts). The queue branch of expandRecipient and of ruleReachesUser pass { onFailedRead: 'refuse' }. expandRecipient feeds the whole-rule pass (desiredGrantsForRule), the recipient-axis revoke (revokeRuleGrantsForRetiredRecipients, which expands before it diffs) and the record pass (expandRecipientForRecord); ruleReachesUser feeds 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 for team and position recipients 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). expandQueueUsers passes { purpose: 'addressing', onFailedRead: 'refuse' }. Part 1's catch in resolveApproverSpec already turns a queue expansion's rejection into ApproverDirectoryUnreadableError before the "expanded to nobody" return, so a queue the registered service cannot read now refuses the node under every onEmptyApprovers policy, the fallback slate included, with the queue's failed read as failedRead.

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-0087 not-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 (its Clause-②: 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 on main; see Acceptance notes.

Tests

All new pins sit in the existing shared harnesses as top-level describes. Each ⛔ case was run against origin/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):
    • a failed sys_queue read rejects expandMembers and isMember, naming the row read, with no warning; the default still answers [] / false, warned once;
    • a failed sys_queue_member read rejects, never the declared positions alone (default: the positions alone);
    • a failed sys_team_member, sys_user_position or sys_member read behind a specification rejects with the exact read sentence, without "refuses instead", and the driver error as cause;
    • isMember under refuse: true when 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 answers false for the same user;
    • facts stay facts: an empty queue [], an unresolved name [] / false, healthy reads equal to the default;
    • a refusal is not remembered: the next call, once the table reads, answers the members;
    • a failed activation-ledger read is not a failed read under refuse either: "switched on", logged.
  • ownership-queue.test.ts, D3 describe, 1 control: with sys_team_member failing, 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 real OwnershipQueueService and SharingRuleService over one engine (queue billing = user u_c + team t_night → u_d): the whole-rule pass rejects and revokes nothing, for a failed sys_queue_member and for a failed sys_team_member; the recipient-axis revoke rejects and retires nobody; the per-user pass rejects for u_d and still answers u_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 a failing set.
  • 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 per onEmptyApprovers policy: 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 with ApproverDirectoryUnreadableError whose failedRead is 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 HEAD empty). Subjects are imported from source.

# mutation red which
A1 the sys_queue row read never refuses 1 of 64 the sys_queue case
A2 the sys_queue_member read never refuses 2 of 64 the sys_queue_member case, the whole-rule pass
A3 a specification's expansion never refuses 6 of 64 the member-table case, the not-remembered case, the whole-rule pass, the revoke, the record pass, the convergence case
A4 the holder check never refuses 2 of 64 the isMember case, the per-user pass
A5 isMember's first failure ends the walk 1 of 64 the isMember case (true past a failed position read)
A6 D7 isMember asks for nobody 1 of 64 the per-user pass
A7 D7 expandMembers asks for nobody 5 of 64 the asked-arguments case, the whole-rule pass, the revoke, the record pass, the convergence case
A8 D6 asks for nobody 5 of 463 the asked-arguments case and the 4 policy cases
A9 the refusal keeps the read's own "refuses instead" 3 of 64 the three cases that pin the read sentence

Gates, 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/main merged once (b66d9c676), then the whole workspace built (pnpm build, 119 of 119 tasks). On head 33a64b2be, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 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/main exited 1 on the in-place edit of part 1's pending changeset (Acceptance notes); b24881883 restores 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:

Run reconciliation — 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 92 derived famil(ies) accounted for — 92 run, 0 NOT-MEASURED (a DERIVED zero — all 92 recorded an exit code and none of them is 3).

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), and typecheck of 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 test under os-verify-lock.sh:

 Test Files  419 passed | 3 skipped (422)
      Tests  3492 passed | 36 skipped (3528)
os-verify-lock: VERDICT command-exit 0 · held the lock 1656s (27m36s)

The permission-set-clone-door failure part 1 reported is gone on this base (main reverted its cause in #30262).

Acceptance notes

  • Activation ledger, decided: no move. The contract says whether its failure posture moves is this card's to decide. It stays "switched on" under both answers: it is the posture the sharing-rule service takes for the same ledger on its own position recipients (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.
  • isMember under refuse answers true when a successful read reaches the user. The contract fixes when false may 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.
  • The queue's own rows refuse first. A failed sys_queue or sys_queue_member read rejects isMember even for a declared queue whose positions might reach the user, since the call cannot know the queue's full membership without them.
  • Follow-up owed, spec wording (no spec file changed here, as dispatched): in 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 that true is answered when a read that succeeded reaches the user, even beside a failed one.
  • Part 1's pending changeset is not edited here; a person decides whether it is. Two of its sentences (the 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, and check-empty-changeset refused 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 on main and 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.
  • Docs correction rides along. content/docs/automation/approvals.mdx had kept part 1's stale sentence (a team / position entry 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.
  • Assignee and reviewers: none set, as dispatched.

Verification

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"
pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-service.test.ts -t "asks the queue service to refuse"

Expect 13 and 4 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CXqXpuVf14Mqo1pznh

claude added 17 commits November 5, 2026 03:39
… 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
…ntry stated as delivered

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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-approvals, @objectstack/plugin-sharing, touching 19 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via expandRecipient (symbol, a method of class SharingRuleService))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))
  • content/docs/releases/v18/18-1.mdx (via ApprovalService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 13 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 870ebce333bf0a5794307febe077ab5652cfbfe9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6af8cf76f92d969649e297be141d21f203f8df6c — the merge of head b24881883d2b1704cded17f71731d95b609febee into base 870ebce333bf0a5794307febe077ab5652cfbfe9, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 870ebce333bf0a5794307febe077ab5652cfbfe9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head b248818 · 2026-11-05T09:01Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b24881883d2b1704cded17f71731d95b609febee
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/ownership-queue-service.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-sharing/src/ownership-queue.test.ts, packages/plugins/plugin-sharing/src/queue-recipient.test.ts, packages/plugins/plugin-approvals/src/approval-service.test.ts + packages/spec/src/contracts/ownership-queue-service.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/queue-hooks.ts, packages/plugins/plugin-sharing/src/bu-tree-recompute.ts, packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/team-graph.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-security/src/kernel-queue-membership.ts, packages/objectql/src/assignment-rules.ts, scripts/check-empty-changeset.mjs, docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md (D2, D6, D7 as amended), and for the one red check-run's provenance packages/spec/liveness/query.json and packages/objectql/src/engine.ts

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 main at the head's merge base 8589756a, +514/−74), the PR's comments (the seat's claim 6653978618 for this head, the Docs Drift Check), and the check-runs on b2488188. The narrowing face: no schema and no governed path in the diff, a Clause-②: yes (narrowing) line in the body and the changeset; the floor is every path outside the prose faces, listed first above, then the files the hunks cite, read at the head (refs/pm-review/30386-head, fetched into a ref of my own; nothing checked out, built or run). Part 1 (PR #30288, my records 6650659075 and 6651366716, merged 13c0c5ba) and the spec slice (#30251, PR #30318) are on the merge base; this is a fresh full record on part 2. CI on the head at posting time: every required context (Build Core, Test Core 1–8, Lint & Repo Gates with every Lint · * lane, TypeScript Type Check with every Type Check · * lane, Governed Surface Queue Guard) and Check Changeset and Dogfood Regression Gate concluded success; the UI and opt-in contexts skipped; ONE check-run is failure, Spec property liveness, whose cause is on main and not in this diff — ③ states the evidence.

① Derived judgments

  1. The service's 'refuse' answer — OwnershipQueueService.expandMembers(name, org, { onFailedRead: 'refuse' }) and isMember(user, name, org, { onFailedRead: 'refuse' }) now reject when the queue's sys_queue row (authoredQueueRow, its own sentence naming the row read), its sys_queue_member walk (authoredMemberSpecs, the walk's refusal passed on), a specification's expansion (expandSpec: team, position with its sys_member limb, the two business-unit kinds) or a holder check (holderCheck) fails; the public call wraps once in queueReadRefusal ([queue] queue '…' in organization '…' does not answer its members, because a read behind it failed: …, the inner read's [sharing] tag and "refuses instead" clause cut, the error as cause). A narrowing of the service's accept set at the answer the contract declares for exactly this: right — ADR-0142 D2 as amended, and the contract's OwnershipQueueFailedReadAnswer whose 'refuse' says "rejects with a failure that names the read, never [], a partial set or false". The paged reads already refuse a failed page or an unverifiable walk, so no refusal is an answer on pages already read. An empty queue is [] and an unresolved name [] / false under both answers (pinned).
  2. The default moves nowhere — (a). Every read keeps its 'nobody' answer under the default (expandSpec, holderCheck, authoredQueueRow, authoredMemberSpecs each guard their throw on refuse), and the only non-test callers passing 'refuse' in the tree are the two D7 sites and the one D6 site. The admission consumers call without it: the sitting-record access path (sharing-service.ts :896 queuesOf, :952 isMember), the claim gate (security-plugin-authz-middleware.ts :1602 isMember), explain-access (:512 isMember), the kernel membership read (kernel-queue-membership.ts :95 queuesOf) and the assignment pool (assignment-rules.ts :293 expandMembers); queuesOf, queuesNaming and hasAnyQueue take no option at all. Right: D3/D4/D5 keep "nobody", as 6594432222 and the brief require. Pinned by the D3 control (team-only member denied, no throw; position member allowed).
  3. D6, the queue arm — expandQueueUsers asks { purpose: 'addressing', onFailedRead: 'refuse' } (:3496); part 1's catch in resolveApproverSpec turns the rejection into ApproverDirectoryUnreadableError ahead of the "expanded to nobody" return, so the node refuses under every onEmptyApprovers policy with the queue's refusal as failedRead; pinned per policy with a double that answers as the real service does. The two axes stay independent (purpose still 'addressing'). decideNode's 'literal' reader meets the same rejection, which its catch records and the queue branch answers with the "could not be expanded" warning and no slot — the slot outcome it had when the service answered nobody. Right — D6 as amended; this delivers part 1's "Not yet".
  4. D7, the grant recompute — (b). expandRecipient's queue branch (:2198) and ruleReachesUser's (:1752) pass 'refuse'. Read in order in the tree: evaluateResolvedRule → desiredGrantsForRule (:2087 expands after findMatchingRecords, before reconcile); revokeRuleGrantsForRetiredRecipients reads the existing grants, expands at :1599, and only then diffs and deletes; reconcileRuleForUser reads the held grants, asks ruleReachesUser at :1679, and only then revokes; evaluateAllForRecord expands at :1422 inside the per-rule try before reconcileForRecord. So every D7 path throws before any write, and the rule's grants stay. Each production caller isolates the rule and warns: the record pass (:1425) and object pass (:1464), the boot backfill (sharing-plugin.ts :237–240), the write-hook reconcile (scheduleReconcile, caught and warned in makeHandler; the chain's own .catch only keeps the chain unpoisoned), queue-hooks.ts (:220, :236), bu-tree-recompute.ts (:263, :301) and recipient-membership-recompute.ts (:319). A direct evaluateRule / revoke / per-user call rejects with the service's failure, the answer a team or position recipient has given since 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. Right — D7 as amended, the plugins: sharing expandRole and 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 isolation shape; pinned through the real service for both outage tables, the revoke, the per-user pass, the record pass, and convergence after the read returns. An inactive rule still purges without expanding — a deactivation is a decision, not an outage.
  5. isMember under 'refuse' — (c). specsReachUser walks every specification; a successful read that reaches the user answers true whichever specification failed; only when none reaches does the first failure become the answer; false is never given beside a failure. Against the contract text: "false is answered only when every read behind it succeeded" holds exactly; "a failed read rejects with a failure naming it" is read as the case where no successful read reaches the user. Right in behaviour: true can only keep or create a grant the user is owed (a member by a read that succeeded is a member), never revoke one, so D7's reason for refusing is untouched, and the alternative would make the answer depend on specification order. The contract sentence should say so — ③ carries it. The queue's own sys_queue / sys_queue_member reads refuse first, before any specification: right, since without them the membership is unknown.
  6. The activation ledger — (d). switchedOff keeps answering "switched on" on a failed read under both answers, logged once; positionActive skips the ledger on 'addressing', so the D6 slate never consults it, and on D7 ('access') it matches positionConfersAccess's posture for a rule's own position recipient, so a queue's position and a rule's position cannot disagree in one outage. The contract left this to the card; the cost (a D7 pass can grant a switched-off position's holders during a ledger outage) predates the card and is stated. Right.
  7. Public surface — the two service methods now take the options the published contract already declares (merged feat(spec): IOwnershipQueueService lets a caller ask for a refusal on a failed member read (onFailedRead) #30318); queueReadRefusal, WALK_CONSEQUENCE, resolve, specReachesUser are module-private; packages/spec untouched; index.ts of both packages untouched. Nothing published widens. The changeset says so and the gates agree.
  8. Tests — the new pins are top-level describes in the shared harness files (ruling 6583487846); the D7 pins drive the real OwnershipQueueService and SharingRuleService over one engine; two existing pins now assert the third argument, so a regression to 'nobody' at either consumer is red.

② Semver level

.changeset/29604-queue-service-refuses-failed-read.md: patch for @objectstack/plugin-sharing and @objectstack/plugin-approvals; ! in the title; a BREAKING banner; Clause-②: yes (narrowing) and Metadata-protocol: none line-initial in the PR body and the changeset; one adr-0087: not-required (no-migration-prescription) marker. No spec key, export or config field moves (the option is the contract's, already published), so the Post-Task Checklist's rule holds: a non-protocol breaking change ships patch with its banner and disposition. Check Changeset is success. Matches what the diff publishes.

Clause-②: yes (narrowing) — right for the diff: the service's 'refuse' answer and its two recompute consumers narrow; nothing on a published entry widens.

Judgment (e), part 1's pending entry: its Clause-②: sentence ("does not reject yet … until #29604 part 2") and its Not yet bullet become false on this merge while both entries are unreleased. The dev first corrected them in place and check-empty-changeset's foreign-changeset rule refused it as designed (a PR may not modify a .changeset/*.md on the merge base it did not add); b2488188 restores the file byte-for-byte from the merge base and the new entry's last bullet names the two sentences and says to read it in their place. As the diff stands this is the only route a seat may take, and Check Changeset is green on it. How the published CHANGELOG would read: both are patch entries of the same packages in the same release section, so a reader of the whole section reads correctly; a reader who greps queue and lands on part 1's entry alone reads "does not reject yet" and is misled — the shape the CHANGELOG guardrail warns of for released entries (a correction elsewhere is one the reader never reaches). ④ should-fix 1 says what is owed before the release consumes the entries.

③ Boundary flags

  • Dev report 6653951527: open_questions: []. Its stated decisions, each read in the tree: (i) the ledger keeps "switched on" — ① item 6, accepted; (ii) isMember answers true past a failed specification — ① item 5, accepted in behaviour, contract wording owed; (iii) the queue's own rows refuse first — accepted; (iv) part 1's pending changeset left as on main, superseded by name, "needs a person's word, not blocking" — ② (e) and ④ should-fix 1; (v) Skills-docs: content/docs/automation/approvals.mdx corrected (part 1's body wrongly said no page stated the failed-read behaviour) — a prose face, not reviewed here; the Docs Drift Check also lists content/docs/automation/flows.mdx via the ApprovalService symbol, and that page names no failed-read or onEmptyApprovers behaviour, so no text moves there; (vi) assignee and reviewers unset — the seat's hand-over step.
  • Out-of-scope finding, class b: three sentences of the published contract (OwnershipQueueFailedReadAnswer: "until then a caller passing 'refuse' still gets 'nobody'"; "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"; isMember's doc not stating true past a failure) become stale or incomplete the moment this merges. The dev marks it "carrier: seat · noted, not filed". A contract text that no longer describes the shipped behaviour is a contract defect, which Prime Directive 10 says is filed, not noted — ④ should-fix 2.
  • The red check-run. Spec property liveness (spec-liveness-check.yml, triggered here by the content/docs/** edit) fails on query/searchFields → packages/objectql/src/engine.ts: the liveness entry (packages/spec/liveness/query.json, identical on this head and on origin/main) cites engine.ts#expandSearchOnAst and the key-mention check finds no searchFields in that file. This diff touches neither packages/spec nor packages/objectql. At this PR's head and merge base engine.ts names $searchFields three times (docblock lines :4898, :4925, :4931); on origin/main (870ebce3) it names it zero times, because f672e0dc (refactor(objectql): move 36 hand-series private docblocks out of engine.ts with the codemod's third pass (slice 1 of 2) #30341, "move 36 hand-series private docblocks out of engine.ts", on main after this PR's merge base) moved those docblocks out. The job checks out the merge of the head into current main, which is where the rot appears; nine of the workflow's twelve most recent runs, on unrelated PR and merge-group heads, fail the same way. The context is not among the ten required status checks on main (read from the branch rules), so it is advisory. Verdict here: not this PR's; it is main's, the objectql/spec lane's to repoint (the query/searchFields evidence to where the docblock now lives), after which a merge of main into this branch clears it. The seat applies the main-red convention to the landing.
  • Nothing outstanding on this PR; nothing escalated.

④ Design judgment

Read against ADR-0142 D2 / D6 / D7 as amended on main and the published contract. The narrowing makes the spec's own refusal real at every point the spec names: D2's "the consumer says which it is owed when it calls" is the contract's onFailedRead; D6's refusal is reached from the real service through part 1's catch; D7's "stop that rule's pass before any revoke, keep the rule's existing grants, log naming the rule and the read" is what each pass does, in that order, with the catches that already isolated a team or position recipient. No declared capability is taken from a consumer: every admission consumer keeps the answer D2 declares for it, and queuesOf (the access path's read) has no refuse axis at all. Tried to break it: a 'refuse' call cannot answer on a partial set (the memo in expandSpec rethrows under refuse, so a later specification cannot mask an earlier failure, and the paged reads refuse rather than truncate); a 'nobody' call cannot reach queueReadRefusal (every throw is guarded on refuse, and the outer catch rethrows raw under the default); isMember's true past a failure can only keep a grant, never revoke one; the inactive-rule purge in evaluateResolvedRule does not expand and so cannot be refused, which is right because deactivation is authored; the write-hook reconcile's chain .catch keeps the chain alive while the handler logs the failure; the D6 arm's rejection under the 'literal' reader lands on the queue branch's failed-read warning and no slot. With part 2 the measured table in 6648911579 (every outage row revoking) is answered: the whole-rule pass, the revoke and the per-user pass now reject before the diff, and the addressing slate refuses the node.

Findings:

  • should-fix 1 — the release must not publish part 1's "does not reject yet" as written (seat, before changeset version; not a code change on this head). Two sentences of the pending .changeset/29604-approver-directory-refusal-queue-paging.md are false once this merges, and the correction lives in a different entry. The gate made that a person's decision by design, and the diff takes the only route a seat may. What is owed: the maintainer's word on the dev's three-line in-place correction (or a maintainer-side edit of the pending note at release), so the published CHANGELOG.md carries no entry a reader can land on alone and be misled by. Record the decision on the card.
  • should-fix 2 — file the spec-lane follow-up (seat). The three contract sentences in packages/spec/src/contracts/ownership-queue-service.ts named in ③ describe a state this merge ends; "noted, not filed" leaves the published contract text wrong. File the card (Metadata-protocol none, docblock only, gen:docs if the TSDoc is rendered) and link it 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 before the card closes on Fixes.

No must-fix.

Implemented-by: claude/project-thread-1wujme
Reviewed-by: session_01Utp6CZMkHvuDeVYgTD73qF

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

席位意见:ACCEPT(#29604 第 2 部分)。调用方传 onFailedRead: 'refuse' 时,ownership-queue 服务在成员读取失败时拒绝而不是答「无人」。审批人名单(D6 queue 臂)和共享规则授权重算(D7)都这样要求,所以名录故障时审批节点拒开,已有授权保留不回收。准入类消费者(D3/D4/D5)不变。

席位核对

Head-sha: b24881883d2b1704cded17f71731d95b609febee · read 2026-11-05T09:25Z

Item Reading
Shape Draft → main, author objectstack-fleet[bot] (relay pr_create); attribution block, then signature line, Fixes #29604, Clause-② / Metadata-protocol / Skills-docs at line start. 8 files, +514/−74. ⛔ packages/spec/** untouched.
Ruling Rulings 6594432222 and 6599731698 (letter B) on #29604; ADR-0142 D2/D6/D7 as amended by PR #29865; seat route A 6648946388 (part 2 after the spec slice). Spec slice #30251 merged by PR #30318 (onFailedRead on expandMembers / isMember). Part 1 merged by PR #30288 (13c0c5baa).
Gates (check-runs on this head) 37 success, 12 skipped, 1 failure. Every required context is green (Build Core, Test Core 1–8, every Lint and Type Check lane, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate). The one red, Spec property liveness, is advisory and is main's: the citation query/searchFields → packages/objectql/src/engine.ts rotted when #30341 (f672e0dc, on main after this PR's merge base) moved the docblocks out of engine.ts. This diff touches neither packages/spec nor packages/objectql, and the same job fails on unrelated heads. Raised with seat 3 (#30053's owner) for the repoint. Dev's local runs: 92/92 derived gates exit 0, lint 0, dogfood 419 files pass, 9 ablations each red, restored.
Contract review CONTRACT_REVIEW_TIER record 6654381568 on this head: PASS, no must-fix. Judged: no admission consumer moved; every D7 path refuses before any write or revoke; isMember under 'refuse' answers true on a successful read that reaches the user, otherwise it throws the first failure; a failed activation-ledger read still counts as on (accepted).
Should-fixes, seat side (1) Part 1's pending changeset still says the service "does not reject yet". This PR's changeset supersedes those two sentences by name, because check-empty-changeset refuses an in-place edit of a pending entry without a person's confirmation. The maintainer's word on a three-line in-place correction before changeset version is being asked separately; it does not hold this landing. (2) The spec docblock follow-up is filed as #30389 (spec lane, serial after this PR).
Semver patch × 2 with !, BREAKING banner, Clause-②: yes (narrowing), Metadata-protocol: none, one adr-0087: not-required (no-migration-prescription) marker; judged right by the record. Nothing new exported.
Docs content/docs/automation/approvals.mdx corrected in this PR. It had kept part 1's stale sentence that a team or position entry whose holder read failed keeps its literal.
Landing Not governed (check-governed-merges --pr 30386: 0 of 8 paths), 588 lines ≤ 3000. Seat lands: pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 09:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft November 5, 2026 09:28
Merged via the queue into main with commit fe835b4 Nov 5, 2026
50 of 51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-1wujme branch November 5, 2026 09:50
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/l tests tooling

Projects

None yet

1 participant