Skip to content

fix(plugins)!: team and position recipients reach every holder past 10,000 rows, and a failed holder read refuses instead of answering nobody - #29602

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-3m8ejg

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29503

domain:server#3 · os-steve · session_01ADHkmT6XzrVS2iPpNyXNYL

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: updated here — content/docs/automation/approvals.mdx now names team and the sys_user_position read beside the sys_member one for a failed holder read; no skill teaches a bound on team or position holders, or what a failed holder read answers.

维护者速读

  • 改了什么:共享规则与审批里「团队成员」和「岗位任职」两类收件人/审批人现在分页读完全部人员;读失败不再当作「没人」,而是明确报错;按单个用户的成员判定(团队/角色/岗位)读失败也报错,不再答「不是成员」。
  • 为什么改:这两处只读一次、上限 1 万行,读失败还会变空:共享规则会漏授权,按用户重算时一次读故障就会撤销这个人的授权;审批会派给缩水的名单(岗位只剩 sys_member 来源)却看起来完整。
  • 风险与回滚:唯一「从有答案变成报错」的情形是这些表的读本身失败(原来答空或 false),属收窄,标题带 !;1 万人以下仍是一次读、结果不变;队列(ownership queue)按文件头声明的「读失败该成员规格贡献 0 人」作答:团队规格不变;岗位规格在 sys_user_position 读失败时,main 会贡献 sys_member.role 过渡源持有人,本 PR 起贡献 0 人,这是声明姿态第一次真正生效,是否改为拒绝留待裁决。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-sharing/src/team-graph.ts(readMembershipToCompletion / probeMembership)、packages/plugins/plugin-sharing/src/ownership-queue-service.ts(holderCheck 保持队列姿态)、packages/plugins/plugin-approvals/src/approval-service.ts(readHoldersToCompletion)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-sharing exec vitest run src/recipient-reads-complete.test.ts,预期 16/16 通过(含 10,001 人全部在集合内、读失败抛错、缺表仍答无人)。
  • 需要你做的:无,席位落地。

What changes

The team and position recipient reads still read once at a 10,000-row bound and turned a failed read into nobody. This completes the family #29362 (PR #29497) started with the two sys_member reads, through the same shared keysetWalk (@objectstack/types). No new spelling of the walk: each plugin now has exactly one walk call for all its holder reads.

Site 1, sys_team_member.

Site 2, sys_user_position. The assignment limb of sharing PositionGraphService.expandPositionUsers and of approvals expandPositionUsers reads to completion. A failed read throws instead of answering the sys_member.role limb alone. The ADR-0091 D2 validity filter in sharing and the raw read in approvals (commit 04d03c3 ruling) are unchanged.

Site 3, the user-scoped holder checks.

  • teamHasMember and holdsRole (limit: 1) keep their one-row probe, which no bound truncates, through probeMembership. Only the catch changed: absence answers false, and anything else throws.
  • PositionGraphService.holdsPosition keeps its 1,000-row page, now as a walk, so an active row behind a long run of lapsed rows still counts. It refuses a failed assignment read instead of falling through to the role check alone.
  • Measured caller: recipient-membership-recompute.ts → SharingRuleService.reconcileRuleForUser → ruleReachesUser. A thrown probe now rejects before any revoke. The recompute's per-(rule, user) catch logs recipient membership re-evaluation failed — this user's grants of the rule may not match …, and the grants stay. On origin/main a failed probe answered false, and reconcileRuleForUser revoked every grant the user held from that rule. Pinned in recipient-membership-recompute.test.ts: a user still on the team, or still assigned, through a second row lost every grant on main.

Site 4, queue expandSpec: left as declared. ADR-0142 does not declare "contributes nobody" on a failed read. D2 says "An empty expansion is a fact, not a fault", and D5 says "A read that fails during evaluation leaves the row as it was before evaluation … and logs at warn" (for assignment rules). The posture is declared in ownership-queue-service.ts's header: "A failed read of a member table contributes nobody for that specification (fail closed for every admission that reads it)". This PR does not narrow it.

  • Because sites 1–3 now throw, the queue's own holder check (specsReachUser) would have started throwing to the access path and the claim admission. A new holderCheck wrapper answers it exactly as expandSpec answers the expansion: that specification contributes nobody, logged once. A team specification's answer is therefore unchanged. A position specification's is not: on a sys_user_position outage it now contributes nobody, where main contributed that position's sys_member.role transition-source holders. That is the posture the queue's header declares for a failed member-table read, reached for the first time. Either failure is now logged where before it was silent. Pinned in ownership-queue.test.ts.
  • The posture question (should a queue sharing recipient refuse too?) is in the report's open_questions.

Site 5, the absence check is homed. isObjectAbsentError(error, readObject) is new in @objectstack/types, beside isMissingTableError. It is the shared missing-table predicate for readObject, or the engine's OBJECT_NOT_FOUND naming readObject. This card adds third and fourth callers (sys_team_member and sys_user_position, in both plugins), so the two membershipObjectAbsent copies are deleted. readObject is required. No new export from @objectstack/core.

What each end caller now sees.

Caller Before (failed read) Now
Sharing whole-rule pass (reconcile, revokeRuleGrantsForRetiredRecipients, per-record pass), team / position recipient team: []; position: role holders only → grants revoked expansion rejects → existing per-rule catch logs, grants stay (refuses where it answered)
Sharing per-user recompute (sys_team_member / sys_user_position write) probe false → user's grants revoked probe rejects → per-pair catch logs, grants stay (refuses where it answered)
Approvals team / position approver team: "expanded to nobody" + literal; position: tier holders only literal slot + could not be expanded — a directory or read-filter lookup failed with error (refuses where it answered)
Approvals escalation (escalateTo position), resolveAs expressions assignment-limb failure swallowed their existing catches take the throw (literal target / no users), as they already do for a sys_member failure since #29497
Approvals reassign door (position:NAME target) assignment-limb failure swallowed the throw reaches the caller, as a sys_member failure already does since #29497
Ownership queue (access, claim, assignment pool, approver slate, queue recipient) team spec contributes nobody; position spec contributes the sys_member.role transition-source holders only both contribute nobody, logged once. Team: unchanged. Position, on a sys_user_position outage: nobody where main gave the transition-source holders (the header's declared posture, reached for the first time)
Absent object (unregistered, or table never created) no holders no holders (unchanged)

Forced test edits (two files, no assertion changed). position-graph.test.ts's assignment fixtures and ownership-queue.test.ts's sys_user_position / sys_team_member fixtures now carry an id on each row, as every stored row does. keysetWalk reports a row without its key as truncated, and the read refuses such a walk. That is the same edit #29497 made to the sys_member fixtures.

Tests

  • packages/plugins/plugin-sharing/src/recipient-reads-complete.test.ts (new). Real engine: ObjectQL + SqlDriver over in-memory SQLite, shipped SysTeamMember and SysMember, scrambled row ids. sys_user_position is declared with the shipped columns, because plugin-security is not a dependency.
    • Site 1: 10,001 members all in the set, the other team out, 2 reads. Control: 9,999, 1 read. A failed read rejects. A failed read is not cached. Unregistered and dropped table answer [].
    • Site 2: 10,001 active holders, other-org and lapsed rows out, 2 reads. Control: 9,999 matching rows, 1 read. A failed assignment read rejects even with a role holder present. Unregistered and dropped table answer the role limb.
    • Site 3: a positive and negative control for all three checks. teamHasMember, holdsRole and holdsPosition each reject on a failed read. Absence of all three objects answers false.
  • recipient-membership-recompute.test.ts (3 added). The team and position cases: deleting one of a user's two rows while the holder read fails keeps the user's grants and logs the re-evaluation failure. A healthy-read control.
  • ownership-queue.test.ts (1 added). The queue posture: a failed team or position read leaves expandMembers and isMember answering nobody for that spec, and logs. Its fixture seeds no sys_member transition row for the position, so main answered nobody there too; the pin that seeds one (where main answered the transition-source holders) is follow-up plugins: a queue sharing recipient still revokes grants on a member-table outage, because the queue expander answers "nobody" for every consumer; refuse for the sharing-grant pass and the approver slate (ADR-0142 amendment first) #29604.
  • packages/plugins/plugin-approvals/src/team-position-holders-complete.integration.test.ts (new, same rig):
    • Team: 10,001 members past the bound, and a team approver routes to all of them. Control: 1 read. A failed read rejects. What the caller sees: the literal plus the failure warning with error. Unregistered: the literal, with no failure warning.
    • Position: 10,001 past the bound. Control: 1 read. A failed assignment read rejects. What the caller sees: the literal, never the tier holder alone. Unregistered and dropped table: the tier limb.
  • packages/types/src/object-absence.test.ts (new). Both absence spellings. An absence attributed to another object, an unattributed OBJECT_NOT_FOUND, and outages are not absence.
Reds on origin/main code (tests committed first, at 5ee0c466; the four source files restored from dbca3ef0)
  • Sharing: 11 failed / 68 passed across the three files. The shapes:
    • expected [ 'usr_000000', …(9998) ] to deeply equal [ …(9999) ] (team and position past the bound);
    • promise resolved "[]" instead of rejecting (team);
    • promise resolved "[ 'usr_role' ]" instead of rejecting (position, role limb alone);
    • promise resolved "false" instead of rejecting (all three checks);
    • expected [] to deeply equal [ 'acct_eu_1:read', 'acct_eu_2:read' ] (recompute team) and the same for :edit (recompute position): the grants were revoked;
    • the queue pin: expected false to be true on the warn only. Its answers ([], false) already held on main for this fixture, which has no sys_member transition row (see the queue row above).
  • Approvals: 7 failed / 5 passed. The past-bound pair for each table, promise resolved "[]" instead of rejecting (team), promise resolved "[ 'usr_tier' ]" instead of rejecting (position), and expected [ 'usr_tier' ] to deeply equal [ 'position:cfo_office' ] (caller sees).
  • Types: Cannot find module './object-absence.js'.
Ablations (each leg through scripts/ablation-replace.mjs: anchor 1→0, blob moved, then restored to blob == HEAD with an empty git diff HEAD; plugin suites import source, so no dist leg)
  • S1, sharing walk break after page 1: 2 failed (team and position past the bound).
  • S2, sharing walk catch → return null: 5 failed (team refuses, not-cached, position refuses, holdsPosition refuses, recompute position).
  • S3, sharing probe catch → return []: 3 failed (teamHasMember, holdsRole, recompute team).
  • Q2, queue holderCheck rethrows: 1 failed (queue posture pin: whether user 'u_d' is a member of team 't_night' could not be read …).
  • A1, approvals walk break after page 1: 3 failed (team ×2, position past the bound).
  • A2, approvals catch → return []: 4 failed (team and position refuse, both caller-sees pins).
  • T2, isObjectAbsentError drops the object attribution: 2 failed (another object's absence, the unattributed OBJECT_NOT_FOUND).
  • First attempts at Q and T were refused by the tool before any run: the anchor and replacement overlapped, so the counts could not move. They were re-anchored as Q2 and T2. No result was read from the refused attempts.

Acceptance notes

  • Clause-② line differs from the claim. The claim says no (narrowing). This diff adds a public export to @objectstack/types (isObjectAbsentError), which widens that package's surface, so the PR and changeset say yes (narrowing), as the card delta's PM assumption foresaw. Flagged to the seat.
  • Review face: node scripts/pm/record-recognisers.mjs --template --paths … names no path face and defers to the changeset's Clause-②: line, whose (narrowing) arm makes it --face narrowing (CONTRACT_REVIEW_TIER).
  • Same-family reads left as they are (outside this card's five sites, listed in the report for the seat):
    • the business-unit member reads: sharing business-unit-graph.ts and primary-bu-projection.ts (limit: 10000), and approvals expandBusinessUnitUsers (limit: 10000, catch → []);
    • the queue service's own sys_queue_member reads (QUEUE_READ_LIMIT);
    • approvals organizationAdministrators (APPROVER_INDEX_CAP, warns when the cap is hit).
  • protocol.ts (frozen) and resolve-authz-context.ts (core/security: the org-members completion read trusts the driver's orderBy and replaces the first read's rows, so a driver that drops the order, or a completion that sees fewer rows, can shorten org_user_ids silently #29398) are untouched.

Verification

Head: 38c62472 (merges origin/main 66ba23e4; the incoming commits touch none of these packages). Every reading below was taken on that head. The one commit after it, 85872264, changes only the changeset's prose (the queue position-spec sentence), so no reading was re-taken. The Dogfood Regression Gate legs skip on pull_request, so the dogfood reading below is the only one on this code.

Package suites and typecheck
  • @objectstack/types: Test Files 35/35, Tests 842/842; typecheck exit 0.
  • @objectstack/plugin-sharing: Test Files 62/62, Tests 1345/1345; typecheck exit 0.
  • @objectstack/plugin-approvals: Test Files 109/109, Tests 1400/1400; typecheck exit 0.
  • pnpm --filter @objectstack/spec check:generated exit 0 (spec moved on main's side of the merge).
Full dogfood battery (the title carries ! and the declaration a (narrowing) arm)
  • Closure build turbo run build --filter='@objectstack/dogfood^...' under the lock: VERDICT command-exit 0, 68/68 tasks.
  • pnpm --filter @objectstack/dogfood test under the lock: exit 0, Test Files 397 passed and 3 skipped (400), Tests 3277 passed and 28 skipped (3305), 0 failed.
Derived gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 93 families derived on 38c62472. All 93 were run, each with its exit code recorded.
  • Three first answered exit 3 (PREREQUISITE NOT MET), so they were re-run after their prerequisite: check-plugin-teardown-shape --self-test after fetching its pinned fixture commit; check:skill-examples and check:dual-build-cjs-loads after turbo run build --filter='./packages/**' (all cache hits). All three then exited 0.
  • --ran reconciliation: 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero).
  • pnpm lint (repo-wide eslint, --no-inline-config): exit 0.
  • CI families are not re-run locally; the PR check-runs are the record.

Generated by Claude Code

…rows and on a failed read

Reds first, against origin/main code: sharing team and position expansions,
the user-scoped holder checks, the per-user recompute, the queue posture, and
the approvals team and position expansions.

Claude-Session: https://claude.ai/code/session_01ADHkmT6XzrVS2iPpNyXNYL
Co-authored-by: Claude <noreply@anthropic.com>
…, and a failed holder read refuses instead of answering nobody

Sharing: TeamGraphService.expandUsers (sys_team_member) and the assignment
limb of PositionGraphService.expandPositionUsers (sys_user_position) read to
completion through keysetWalk; the user-scoped checks teamHasMember, holdsRole
and holdsPosition refuse a failed read instead of answering false. The queue
service keeps its declared posture (a failed member read contributes nobody)
on its holder check too.

Approvals: the team approver and the position assignment limb read to
completion through one readHoldersToCompletion beside the tier read.

Absence is judged once, by the new isObjectAbsentError in @objectstack/types.

Claude-Session: https://claude.ai/code/session_01ADHkmT6XzrVS2iPpNyXNYL
Co-authored-by: Claude <noreply@anthropic.com>
The team and position reads page by id now and refuse a row they cannot
resume after; stored rows always carry one. The position control seeds 9,999
matching rows (the lapsed assignment matches the predicate too).

Claude-Session: https://claude.ai/code/session_01ADHkmT6XzrVS2iPpNyXNYL
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-approvals, @objectstack/plugin-sharing, @objectstack/types, touching 32 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/types/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fdb9a072a9a8ce50807ba0ce638264739a022158.

⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/types/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 23 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 fdb9a072a9a8ce50807ba0ce638264739a022158 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2cda891e047c7fa12b7aee6708400bb313557224 — the merge of head 85872264d954a170e95b6787db3fbad904275c77 into base fdb9a072a9a8ce50807ba0ce638264739a022158, 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 2cda891e047c7fa12b7aee6708400bb313557224 && git checkout 2cda891e047c7fa12b7aee6708400bb313557224
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fdb9a072a9a8ce50807ba0ce638264739a022158 85872264d954a170e95b6787db3fbad904275c77 && git checkout -B drift-repro fdb9a072a9a8ce50807ba0ce638264739a022158 && git merge --no-ff 85872264d954a170e95b6787db3fbad904275c77

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

⚠️ 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 fdb9a072a9a8ce50807ba0ce638264739a022158 → 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#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 38c6247 · 2026-11-02T20:53Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 38c6247211999216b682c58bfbeace9814d2f07a
Local-runs: none
Scope: packages/types/src/object-absence.ts, packages/types/src/object-absence.test.ts, packages/types/src/index.ts, packages/plugins/plugin-sharing/src/team-graph.ts, packages/plugins/plugin-sharing/src/position-graph.ts, packages/plugins/plugin-sharing/src/ownership-queue-service.ts, packages/plugins/plugin-sharing/src/recipient-reads-complete.test.ts, packages/plugins/plugin-sharing/src/recipient-membership-recompute.test.ts, packages/plugins/plugin-sharing/src/ownership-queue.test.ts, packages/plugins/plugin-sharing/src/position-graph.test.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-approvals/src/team-position-holders-complete.integration.test.ts + packages/types/src/keyset-walk.ts, packages/types/src/driver-error-classification.ts (matchesDriverError, MISSING_TABLE, namesAnotherRelation), packages/types/package.json, packages/core/src/utils/object-not-found.ts, packages/objectql/src/engine.ts (resolveObjectName), packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts (reconcileRuleForUser, ruleReachesUser, revokeRuleGrantsForRetiredRecipients, evaluateAllForRecord, evaluateAllRulesForObject), packages/plugins/plugin-sharing/src/bu-tree-recompute.ts, packages/plugins/plugin-sharing/src/queue-hooks.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/index.ts, packages/spec/src/contracts/sharing-service.ts (ITeamGraphService), packages/spec/src/contracts/ownership-queue-service.ts, docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md (D2, D5, D6, D7), packages/platform-objects/src/identity/sys-team-member.object.ts, packages/plugins/plugin-security/src/objects/sys-user-position.object.ts, packages/drivers/driver-memory/src/filter-refusal.ts, .github/workflows/ci.yml (dogfood legs and dogfood-gate), scripts/pm/clause2-line.mjs (readClause2Line)

Inputs read: card #29503 body and all five comments (triage 6583253479, claim 6586753163, dev report 6588119628, claim amendment 6588159994, seat ruling 6588178193); PR #29602 body, 14-path file list, net diff origin/main...pr29602 (merge-base 66ba23e4); the 62 check-runs on the head, polled twice.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged against the tree:

  1. Sharing TeamGraphService.expandUsers (sys_team_member). Now readMembershipToCompletion → keysetWalk pages of 10,000 ordered by id, projection ['id','user_id']. Failed read: holderReadRefusal throws with the driver error as cause, before cache.expandUsers.set, so nothing is cached. Truncated walk (walk.truncated): throws the WALK_INCOMPLETE refusal. Absent object (isObjectAbsentError(err,'sys_team_member')): null → [], cached as before. Right. The published contract (ITeamGraphService.expandUsers, sharing-service.ts L664) declares only "all user ids that are members of teamId"; it never declared an empty answer on failure, so no declared capability is taken away (see ④).
  2. sys_user_position limb of sharing PositionGraphService.expandPositionUsers. Same walk, projection adds valid_from/valid_until; the ADR-0091 D2 isGrantActive filter is unchanged. A throw leaves cache.expand unset (the set is after both limbs); absence falls through to the expandRoleUsers transition limb, as before. Right. The filter → where key change is immaterial: ObjectQL treats filter as an alias slot of where (engine.ts RPC_QUERY_ALIAS_SLOTS), and the pre-existing 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 walk already used where against the real engine.
  3. teamHasMember / holdsRole. probeMembership keeps limit: 1 (a one-row existence probe is never cut), absence → [] → false, every other failure throws a 'check'-worded refusal. Right. holdsPosition became a 1,000-row walk (pageRows: 1000, kind: 'check'), refusing a failed assignment read instead of falling through to holdsRole; absence still falls through. Right — on main a user with more than 1,000 rows (lapsed history) whose only active row sat past the bound answered false, the same defect class as site 2.
  4. Who catches, and what the end caller sees (read in the cited code, not the PR body):
    • Per-user recompute (recipient-membership-recompute.ts L305-L323): reconcileRuleForUser → ruleReachesUser → the probe rejects before the revoke loop; the per-(rule,user) catch warns recipient membership re-evaluation failed, grants stay. On main a false revoked every grant the user held from that rule (L1679-L1685). Kept grant — right, pinned in the 3 added recompute tests on the real engine.
    • Whole-rule passes: evaluateAllForRecord (per-rule catch L1424), evaluateAllRulesForObject (L1463), boot backfill and rebind in sharing-plugin.ts (L237, L510), bu-tree-recompute.ts (L290-L304) and queue-hooks.ts (L206-L235) around revokeRuleGrantsForRetiredRecipients / evaluateRule — every caller catches per rule and warns; the rule's existing grants stay. Refused bulk pass, grants kept — right.
    • Approvals team / position approver: resolveApproverSpec's one try (L2566-L2603) sets directoryFailure, the slot falls back to its type:value literal and logs could not be expanded — a directory or read-filter lookup failed with error. Skipped tier with a warning naming the failed read — right, pinned in the two caller-sees tests.
    • Approvals resolveAs expressions (L2842-L2846): the existing catch swallows to users = [] and pushes the resolveAs:key literal slot with no warning — the same posture fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody #29497 accepted for a sys_member failure. Not a site of this card; noted for the seat below.
    • Approvals escalation (escalateTo, L7689-L7704): catch → literal → reassignTargetRefusal re-expands and the throw propagates out of the sweep; the next sweep retries (the body's account matches the code). Reassign door (assertReassignTargetIsNotSubmitter, reassignTargetRefusal): the throw reaches the caller as the request's error, as a sys_member failure already does since fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody #29497 — a server-side failure, never a refusal naming the submitter.
  5. Approvals expandTeamUsers through the new readHoldersToCompletion (one walk for tier, position and team; HOLDER_PAGE_ROWS 10000). teamIsProvablyOutsideOrg / dropMembersProvablyOutsideOrg are outside the walk and unchanged. Right.
  6. isObjectAbsentError attribution. isMissingTableError(error, readObject) → matchesDriverError: at every node relation = targetedTableOf(err) ?? readObject, and MISSING_TABLE's excludes.namesAnotherRelation(message, relation) returns false for a missing-table phrase naming a different relation before any code/errno/message match. The OBJECT_NOT_FOUND half compares object === readObject strictly; the engine's envelope (objectNotFoundError, core) always sets object. A real outage (SQLITE_IOERR, connection terminated, lock timeout, permission refusal) matches no MISSING_TABLE code, errno or phrase → false → refusal. Residual: a code-only PG 42P01 with no message would be attributed to readObject; both reads are plain-equality where with no relation path, so the only relation the statement touches is the object itself. Right; object-absence.test.ts pins another object's absence (both spellings), the unattributed code and three outages.
  7. keysetWalk termination and completeness. No max, so truncated arises only from a row without id or a cursor that did not advance (judged by string equality on last/first, never JS order). The cursor strictly advances on every full page under a seeking driver, the set is finite, so the walk terminates; a non-seeking reader is reported truncated and refused, never spun. Completeness holds on SqlDriver (SQLite, PG, MySQL: ORDER BY and the $gt compare run under the same column collation) and on driver-memory ($gt is an executed operator). Both objects carry a text id (sys-team-member.object.ts L127, sys-user-position.object.ts L61). The 10,001-row tests on the real SQLite engine with scrambled ids count exactly 2 reads. Right.
  8. holderCheck in ownership-queue-service.ts. Team: teamHasMember answered false on main (internal catch) and answers false now (wrapper catch + warnOnce), so the queue's team answer is byte-identical. Position: on main holdsPosition swallowed a failed sys_user_position read and fell through to holdsRole, and expandPositionUsers fell through to the sys_member.role limb — so on that outage a queue position spec contributed the transition-source holders; now holderCheck / expandSpec answer false / []. That is exactly the header's declared posture ("a failed read of a member table contributes nobody for that specification"), reached for the first time — right against the declaration, but the PR body's row "Ownership queue … unchanged" and the changeset's "still contributes nobody" overstate it for the transition-limb case, and the ownership-queue.test.ts pin (fixture without sys_member) cannot see the difference. Should-fix (prose + pin), see ④.
  9. Public surface. @objectstack/types gains isObjectAbsentError via export * from './object-absence.js' on the . entry (package.json exports → dist/index). readMembershipToCompletion and probeMembership are module exports of team-graph.ts but plugin-sharing/src/index.ts L49 exports only TeamGraphService, expandPrincipal, TeamGraphOptions — not public. No @objectstack/core export. Right.

② Semver level

Clause-②: yes (narrowing) — the single line-initial declaration on both carriers (PR body L8, changeset L9); readClause2Line returns the first declaration and skips the body's L98 bullet (no colon-key, describing) and L99 (backticked inline key), so the level axis is measured. Metadata-protocol: none on both — right, no authorable key, value or type changes. Changeset .changeset/29503-recipient-reads-complete.md: @objectstack/types: patch, @objectstack/plugin-sharing: patch, @objectstack/plugin-approvals: patch. Right under AGENTS.md's rule ("a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner"): the new export is a non-protocol widening of types, and the refusals are a non-protocol narrowing of the two plugins. The types package takes a level, which the claim amendment required. The ! title (fix(plugins)!:) and the BREAKING banner are present on both carriers; the ADR-0087 marker is one (not-required (no-migration-prescription)), correct since no author has anything to rewrite. The yes value is right: a new export is a widening in application code. Check Changeset and every Lint gate (which run check-changeset-no-major) concluded success on the head.

③ Boundary flags

  • Approvals expandTeamUsers fold-in (not a numbered site, named by the card title): same table and defect as site 1, same file as site 2, same gate family, declared in the report and PR body. Within the in-place exemption. Confirmed.
  • ownership-queue-service.ts changed although the claim said "only if ADR-0142 allows": the change is the forced consequence of sites 1-3 throwing — without holderCheck, specsReachUser would have started throwing into the access path and claim admission, a narrowing nobody ordered. ADR-0142 read: D2 says "An empty expansion is a fact, not a fault: the consumers below each say what they do with it"; D5 covers only an assignment-rule evaluation ("leaves the row as it was … logs at warn"); D6/D7 say what an empty slate/expansion does; the published IOwnershipQueueService header declares null/[]/false for a name that resolves nowhere and [] for an empty queue — nothing declares the failure posture. Only the service header does. The change keeps that header's posture. Confirmed; the ADR allows it because it does not speak.
  • holdsPosition 1,000-row read becoming a walk: right (①.3); pageRows keeps the one-read case one read.
  • Forced fixture-id edits in ownership-queue.test.ts and position-graph.test.ts: every row gains an id, no assertion changed (diff read whole); required because keysetWalk reports a row without its key as truncated and the read now refuses that. The same edit fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody #29497 made for sys_member. Confirmed.
  • Dogfood reading vs the head's check-run. The Dogfood Regression Gate check-run concluded success, but its own log reads leg dogfood (declared roster 1..3/3) — aggregate result: skipped and leg dogfood-verify — skipped: by ci.yml design the three dogfood shards and Dogfood Verify CLI carry github.event_name != 'pull_request' in their if: and dogfood-gate counts zero expected attestations on a PR. So the head's dogfood check-run measures nothing; the only dogfood reading on this head is the dev's local battery (closure build 68/68, Test Files 397 passed / 3 skipped, Tests 3277 passed / 28 skipped, 0 failed), a dev reading, not a gate. The battery is measured on the push to main. Named here so the green is not read as a measurement.
  • Check-runs on the head at review time: 62 runs. Completed success: Build Core, Build & E2E UI, Build Docs, Build UI Deps, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (legs skipped, above), Flag docs, Governed Surface Queue Guard, every Lint job (CI/release/engine gates, ESLint, PM dispatch-gates, docs & code guards, repo & tooling gates, three scoped), Live E2E UI, PR-body guards, Plan Test Core shards, Spec property liveness, Type Check consumer gates / debt ledger / source gates, filter, Auto Label. Skipped by design: 18.x harness, dogfood shards, Dogfood Verify CLI, Lint UI, Packed-tarball smoke, Schedule gate, Temporal Conformance, UI Published Artifacts. Still in progress: Test Core (1/8 to 8/8), Test UI (1/16 to 16/16), Type Check UI, Type Check · workspace. The landing waits on those; this record's verdict is on the diff and the completed gates.
  • open_questions[0] (site 4): seat ruled A on this PR, B as a follow-up card after an ADR-0142 amendment. Confirmed. B per consumer contradicts D2's one-expander sentence until amended, and docs/adr/** is Tier H, so folding it in would govern the whole PR. C is wrong as a direction: the access path and claim admission are correctly fail-closed. One addition for the B card: the position transition-limb case in ①.8 belongs to it (the queue's sharing-recipient pass through isMember now revokes on a sys_user_position outage where main kept the transition-source holders).
  • open_questions[1]: seat settled A (yes (narrowing)). Confirmed (②).
  • Out-of-scope findings (business-unit reads, sys_queue_member at QUEUE_READ_LIMIT, organizationAdministrators at APPROVER_INDEX_CAP) are correctly left unfiled by the dev and listed for the seat; the resolveAs silent swallow (①.4) is a fourth candidate of the same class.
  • The three stray scratch files at the container root the report confesses are outside the tree and the PR; nothing in the diff carries them.

④ Design judgment

The narrowing, read against the published spec on main: ITeamGraphService.expandUsers and expandRoleUsers declare "all user ids that are members / hold the role" and nothing about failure; IOwnershipQueueService declares only the resolves-nowhere and empty-queue answers. No published contract ever declared "a failed read answers nobody", so this PR takes no declared capability away at a consumer — it makes a silent failure (an invented empty set that the reconcilers diffed into revocations, and a shrunk slate that looked complete) loud, which is exactly what the card asks. The one declared failure posture in the tree (the queue service header) is kept, through holderCheck, and the open question of whether it should change is routed to a ruling with the ADR amendment it needs. Pattern-wise the diff reduces copies: one walk per plugin, one absence predicate in types replacing two drifting membershipObjectAbsent copies, one refusal sentence family.

Attempts to break it, each read in the live tree:

  • Infinite walk on a driver that ignores the seek: refused as truncated by the equality guard, never a hang (①.7).
  • Absence of another object read as this one's, or an outage read as absence: excluded by namesAnotherRelation and the strict object compare; outages match no MISSING_TABLE signature (①.6).
  • A cached failure: cache.set sits after the walk in both services; the not-cached test pins it on the real engine.
  • A new 5xx at a request door: only the approvals reassign door and the escalation sweep propagate, and both already did so for sys_member since fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody #29497; the sharing doors all catch per rule or per pair.
  • A new refusal or a new "nobody" in the queue: team byte-identical; position moves from "transition-limb holders" to the header's declared "nobody" on a sys_user_position outage (①.8).

Findings:

  • Should-fix (prose + pin, not blocking): the PR body's queue row and the changeset's "Ownership queues keep their declared posture … still contributes nobody" should say that on a sys_user_position outage a position specification now contributes nobody where it contributed the sys_member.role transition-source holders (the header's posture, newly reached); and the ownership-queue.test.ts posture pin should seed one sys_member transition row so the pin can see that answer. Either fold into the B follow-up card or amend the changeset body on this head.
  • Should-fix (follow-up, not this card): approvals resolveAs expression catch (approval-service.ts L2842-L2846) swallows the refusal to a literal slot with no could not be read warning — pre-existing since fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody #29497 for sys_member, now reached by team and position too. A class-a note for the seat's filing, same family.
  • No must-fix.

Implemented-by: claude/project-thread-3m8ejg
Reviewed-by: session_01ADHkmQJdgarUjPLNgFBY67

VERDICT: PASS

…a sys_user_position outage

On a sys_user_position outage a queue position specification used to
contribute the position's sys_member.role transition-source holders; it now
contributes nobody. That is the queue's declared posture, reached for the
first time. Prose only.

Claude-Session: https://claude.ai/code/session_01ADHkmT6XzrVS2iPpNyXNYL
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29602 · 2026-11-02T21:37Z

Head-sha: 85872264d954a170e95b6787db3fbad904275c77

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main,Fixes #29503。14 个文件,在认领及其修订 6588159994 的申报面内。被迫测试改动只有一类,ownership-queue.test.ts 和 position-graph.test.ts 的夹具补 id,断言不变(与 fix(plugins)!: sharing role and approvals membership-tier holders are read past 10,000 rows, and a failed membership read refuses instead of answering nobody #29497 同理,复审逐行读过)。
  2. 前提实测:narrowing 面契约复审 PASS,记录在本 PR 的 ## Contract review,CONTRACT_REVIEW_TIER,head 38c62472,独立会话。复审逐条核对了每处读取在失败、截断、对象缺失三种情况下由谁捕获、终端调用方看到什么,也核对了 isObjectAbsentError 不会把真故障或别的对象缺失误判为本对象缺失,以及翻页在 SQL 与内存驱动上会终止且读全。复审之后只多了一个提交(38c62472..85872264,仅 .changeset/29503-recipient-reads-complete.md 一行),按复审 should-fix 1 更正队列措辞,是纯 prose。席位按 diff 核过,不另起复审。
  3. 门禁:本 head 全部 check-run 已读,59 success、10 skipped、0 失败,含 Test Core 1–8/8、Test UI、Type Check 各项、Check Changeset。PR 上的 Dogfood Regression Gate 按 ci.yml 设计跳过分片,不算测量,只是零证明的通过。dogfood 唯一读数是 dev 在 38c62472 上本地跑的整套:397 个文件通过、3 个跳过,3277 条测试通过,0 失败。之后只改了 changeset prose,不需重跑。dev 读数:main 上红 sharing 11 条、approvals 7 条;8 条消融全部转红后复原;dispatch-gates 93/93;pnpm lint 0。
  4. 接受的偏差:审批 expandTeamUsers 按就地豁免并入,同类、同文件。ownership-queue-service.ts 加了 holderCheck,用来保住已声明的队列姿态;这是 1–3 处改为报错后必须做的配套,复审对照 ADR-0142 确认 ADR 没有禁止。holdsPosition 原来一次读 1,000 行,现在改为翻页读。
  5. 开放问题裁定:Q1(site 4)本 PR 选 A(6588178193),B 方向另立 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(先改 ADR-0142,再按用途拒绝)。复审的两条 should-fix 都已安置:第 1 条的措辞在本 head 修正,配套的钉子挂到 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;第 2 条(审批 resolveAs 静默吞错)挂到 plugins: the business-unit member reads in sharing and approvals still stop at 10,000 rows and answer "nobody" on a failed read; the queue member and approver-index reads stop at their caps #29606。Q2 选 A,即 yes (narrowing),认领已修订。dev 的业务单元读取等范围外发现已立 plugins: the business-unit member reads in sharing and approvals still stop at 10,000 rows and answer "nobody" on a failed read; the queue member and approver-index reads stop at their caps #29606。
  6. 落地条款:Clause-②: yes (narrowing),Metadata-protocol: none,PR 正文与 changeset 都有。标题带 !,有 BREAKING 横幅,并带 adr-0087: not-required (no-migration-prescription) 标记。semver:@objectstack/types、@objectstack/plugin-sharing、@objectstack/plugin-approvals 均为 patch(非协议变更)。check-governed-merges --pr 29602:NOT governed。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

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/xl tests tooling

Projects

None yet

1 participant