Repository navigation
fix(plugins)!: team and position recipients reach every holder past 10,000 rows, and a failed holder read refuses instead of answering nobody - #29602
Conversation
…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>
📓 Docs Drift CheckThis PR changes 3 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract-review-claim: session_01ADHkmQJdgarUjPLNgFBY67 · head 38c6247 · 2026-11-02T20:53Z |
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 Contract reviewServed-tier: 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 ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged against the tree:
② Semver levelClause-②: yes (narrowing) — the single line-initial declaration on both carriers (PR body L8, changeset L9); ③ Boundary flags
④ Design judgmentThe narrowing, read against the published spec on Attempts to break it, each read in the live tree:
Findings:
Implemented-by: 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>
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29602 · 2026-11-02T21:37ZHead-sha: 维护者速读(席位终稿)
核对六项
|
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.mdxnow namesteamand thesys_user_positionread beside thesys_memberone for a failed holder read; no skill teaches a bound on team or position holders, or what a failed holder read answers.维护者速读
!;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_memberreads, through the same sharedkeysetWalk(@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.TeamGraphService.expandUsers(everyteamsharing recipient, and queue team members) reads to completion throughreadMembershipToCompletion, the generalised plugins: sharingexpandRoleand approvals role-tier recipients read sys_member at a 10,000-row bound and turn a failed read into an empty list, so a large or unreadable membership silently shrinks the set #29362 helper inteam-graph.ts. A failed or incomplete read throws, and nothing is cached for it.expandTeamUsers(theteamapprover, andresolveAs: 'team'expressions) reads throughreadHoldersToCompletion, the generalised plugins: sharingexpandRoleand approvals role-tier recipients read sys_member at a 10,000-row bound and turn a failed read into an empty list, so a large or unreadable membership silently shrinks the set #29362 tier read inapproval-service.ts. This is the same table and defect in the second plugin the card title names. It is folded in under os-dev's bounded in-place exemption (same class, mechanical, same file as the card's site 2, same gate family) and declared in the report.Site 2,
sys_user_position. The assignment limb of sharingPositionGraphService.expandPositionUsersand of approvalsexpandPositionUsersreads to completion. A failed read throws instead of answering thesys_member.rolelimb 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.
teamHasMemberandholdsRole(limit: 1) keep their one-row probe, which no bound truncates, throughprobeMembership. Only the catch changed: absence answersfalse, and anything else throws.PositionGraphService.holdsPositionkeeps 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.recipient-membership-recompute.ts→SharingRuleService.reconcileRuleForUser→ruleReachesUser. A thrown probe now rejects before any revoke. The recompute's per-(rule, user) catch logsrecipient membership re-evaluation failed — this user's grants of the rule may not match …, and the grants stay. Onorigin/maina failed probe answeredfalse, andreconcileRuleForUserrevoked every grant the user held from that rule. Pinned inrecipient-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 atwarn" (for assignment rules). The posture is declared inownership-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.specsReachUser) would have started throwing to the access path and the claim admission. A newholderCheckwrapper answers it exactly asexpandSpecanswers the expansion: that specification contributes nobody, logged once. A team specification's answer is therefore unchanged. A position specification's is not: on asys_user_positionoutage it now contributes nobody, where main contributed that position'ssys_member.roletransition-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 inownership-queue.test.ts.open_questions.Site 5, the absence check is homed.
isObjectAbsentError(error, readObject)is new in@objectstack/types, besideisMissingTableError. It is the shared missing-table predicate forreadObject, or the engine'sOBJECT_NOT_FOUNDnamingreadObject. This card adds third and fourth callers (sys_team_memberandsys_user_position, in both plugins), so the twomembershipObjectAbsentcopies are deleted.readObjectis required. No new export from@objectstack/core.What each end caller now sees.
reconcile,revokeRuleGrantsForRetiredRecipients, per-record pass),team/positionrecipient[]; position: role holders only → grants revokedsys_team_member/sys_user_positionwrite)false→ user's grants revokedteam/positionapprovercould not be expanded — a directory or read-filter lookup failedwitherror(refuses where it answered)escalateToposition),resolveAsexpressionssys_memberfailure since #29497position:NAMEtarget)sys_memberfailure already does since #29497sys_member.roletransition-source holders onlysys_user_positionoutage: nobody where main gave the transition-source holders (the header's declared posture, reached for the first time)Forced test edits (two files, no assertion changed).
position-graph.test.ts's assignment fixtures andownership-queue.test.ts'ssys_user_position/sys_team_memberfixtures now carry anidon each row, as every stored row does.keysetWalkreports a row without its key astruncated, and the read refuses such a walk. That is the same edit #29497 made to thesys_memberfixtures.Tests
packages/plugins/plugin-sharing/src/recipient-reads-complete.test.ts(new). Real engine: ObjectQL + SqlDriver over in-memory SQLite, shippedSysTeamMemberandSysMember, scrambled row ids.sys_user_positionis declared with the shipped columns, because plugin-security is not a dependency.[].teamHasMember,holdsRoleandholdsPositioneach reject on a failed read. Absence of all three objects answersfalse.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 leavesexpandMembersandisMemberanswering nobody for that spec, and logs. Its fixture seeds nosys_membertransition 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):error. Unregistered: the literal, with no failure warning.packages/types/src/object-absence.test.ts(new). Both absence spellings. An absence attributed to another object, an unattributedOBJECT_NOT_FOUND, and outages are not absence.Reds on
origin/maincode (tests committed first, at5ee0c466; the four source files restored fromdbca3ef0)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;expected false to be trueon the warn only. Its answers ([],false) already held on main for this fixture, which has nosys_membertransition row (see the queue row above).promise resolved "[]" instead of rejecting(team),promise resolved "[ 'usr_tier' ]" instead of rejecting(position), andexpected [ 'usr_tier' ] to deeply equal [ 'position:cfo_office' ](caller sees).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 emptygit diff HEAD; plugin suites import source, so no dist leg)breakafter page 1: 2 failed (team and position past the bound).return null: 5 failed (team refuses, not-cached, position refuses,holdsPositionrefuses, recompute position).return []: 3 failed (teamHasMember,holdsRole, recompute team).holderCheckrethrows: 1 failed (queue posture pin:whether user 'u_d' is a member of team 't_night' could not be read …).breakafter page 1: 3 failed (team ×2, position past the bound).return []: 4 failed (team and position refuse, both caller-sees pins).isObjectAbsentErrordrops the object attribution: 2 failed (another object's absence, the unattributedOBJECT_NOT_FOUND).Acceptance notes
no (narrowing). This diff adds a public export to@objectstack/types(isObjectAbsentError), which widens that package's surface, so the PR and changeset sayyes (narrowing), as the card delta's PM assumption foresaw. Flagged to the seat.node scripts/pm/record-recognisers.mjs --template --paths …names no path face and defers to the changeset'sClause-②:line, whose(narrowing)arm makes it--face narrowing(CONTRACT_REVIEW_TIER).business-unit-graph.tsandprimary-bu-projection.ts(limit: 10000), and approvalsexpandBusinessUnitUsers(limit: 10000, catch →[]);sys_queue_memberreads (QUEUE_READ_LIMIT);organizationAdministrators(APPROVER_INDEX_CAP, warns when the cap is hit).protocol.ts(frozen) andresolve-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(mergesorigin/main66ba23e4; 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 onpull_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:generatedexit 0 (spec moved onmain's side of the merge).Full dogfood battery (the title carries
!and the declaration a(narrowing)arm)turbo run build --filter='@objectstack/dogfood^...'under the lock:VERDICT command-exit 0, 68/68 tasks.pnpm --filter @objectstack/dogfood testunder 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 on38c62472. All 93 were run, each with its exit code recorded.check-plugin-teardown-shape --self-testafter fetching its pinned fixture commit;check:skill-examplesandcheck:dual-build-cjs-loadsafterturbo run build --filter='./packages/**'(all cache hits). All three then exited 0.--ranreconciliation:93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN(a DERIVED zero).pnpm lint(repo-wide eslint,--no-inline-config): exit 0.Generated by Claude Code