Skip to content

Commit 9bdfe37

Browse files
fix(plugins)!: team and position recipients reach every holder past 10,000 rows, and a failed holder read refuses instead of answering nobody (#29602)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi91ow1LxYabwyJZre5CQfiY4)_ 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`.** - Sharing `TeamGraphService.expandUsers` (every `team` sharing recipient, and queue team members) reads to completion through `readMembershipToCompletion`, the generalised #29362 helper in `team-graph.ts`. A failed or incomplete read throws, and nothing is cached for it. - Approvals `expandTeamUsers` (the `team` approver, and `resolveAs: 'team'` expressions) reads through `readHoldersToCompletion`, the generalised #29362 tier read in `approval-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 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 #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. <details><summary>Reds on <code>origin/main</code> code (tests committed first, at <code>5ee0c466</code>; the four source files restored from <code>dbca3ef0</code>)</summary> - 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'`. </details> <details><summary>Ablations (each leg through <code>scripts/ablation-replace.mjs</code>: anchor 1→0, blob moved, then restored to blob == HEAD with an empty <code>git diff HEAD</code>; plugin suites import source, so no dist leg)</summary> - 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. </details> ## 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` (#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. <details><summary>Package suites and typecheck</summary> - `@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). </details> <details><summary>Full dogfood battery (the title carries <code>!</code> and the declaration a <code>(narrowing)</code> arm)</summary> - 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. </details> <details><summary>Derived gates</summary> - `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. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmT6XzrVS2iPpNyXNYL)_ <!-- signed: domain:server#3 · os-steve · session_01ADHkmT6XzrVS2iPpNyXNYL --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0bfb225 commit 9bdfe37

14 files changed

Lines changed: 1029 additions & 203 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/plugin-sharing': patch
4+
'@objectstack/plugin-approvals': patch
5+
---
6+
7+
fix(plugins)!: team and position recipients reach every holder past 10,000 rows in sharing and approvals, and a team, position or role holder read that fails is refused loudly instead of answered with nobody
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata changes: the team and position expansions read lists they already read, to completion, and the holder checks refuse a read that already failed. The one new export (isObjectAbsentError in @objectstack/types) is an addition. No author has anything to rewrite. -->
13+
14+
**BREAKING** only where a `sys_team_member`, `sys_user_position` or `sys_member` read fails: the team and position expansions and the user-scoped holder checks used to answer nobody (an empty list, or `false`) then, and now refuse.
15+
16+
This completes the family the role and membership-tier fix started. The team and position reads still read once with a 10,000-row bound and turned any read error into nobody.
17+
18+
- **Every holder, nobody else.** In plugin-sharing, `TeamGraphService.expandUsers` (`sys_team_member`, every `team` sharing recipient) and the assignment limb of `PositionGraphService.expandPositionUsers` (`sys_user_position`, every `position` sharing recipient) now read in pages of 10,000 ordered by id until the holders are exhausted. In plugin-approvals, the `team` approver and the assignment limb of the `position` approver (and every caller of the position expansion: escalation targets, reassign checks, `resolveAs` expressions) do the same. Past 10,000 holders, the holders past the 10,000th row used to get no grant and no approval slot. They now do.
19+
- **Smaller groups are unchanged.** A team or position held by fewer than 10,000 people issues one read, as before, and answers the same set.
20+
- **Loud, never short or empty.** A read that fails, or a page the driver returned without honouring the id cursor, now throws.
21+
- Sharing, whole-rule passes: the expansion rejects, so that rule's reconcile or revoke pass fails, its callers log their existing per-rule warning, and the grants it already holds stay. Before, a failed team read answered `[]`, and a failed assignment read answered the `sys_member.role` holders alone, so the retired-recipient revoke took grants away from people who still held the recipient.
22+
- Sharing, per-user recompute (a write to `sys_team_member` or `sys_user_position`): the holder checks `teamHasMember`, `holdsRole` and `PositionGraphService.holdsPosition` now refuse a failed read instead of answering `false`. The recompute logs `recipient membership re-evaluation failed` for that rule and user, and the user's grants stay. Before, an outage revoked that user's grants.
23+
- Approvals: the approver slot falls back to its literal (`team:ID` or `position:NAME`) and logs `could not be expanded — a directory or read-filter lookup failed` with its `error`. Before, a failed team read logged "expanded to nobody", and a failed assignment read routed a `position` approver to its membership-tier holders alone, which looked like a complete slate.
24+
- **Ownership queues: a position specification now contributes nobody on a `sys_user_position` outage.** A queue team specification whose `sys_team_member` read fails still contributes nobody, as before. A queue position specification whose `sys_user_position` read fails now contributes nobody too, on the expansion and on the queue's holder check alike, where before it contributed that position's `sys_member.role` transition-source holders. That is the posture the queue declares for a failed member-table read, reached here for the first time. Both now log once. Whether a queue should refuse instead is left to a ruling.
25+
- **A missing object is still no holders.** A stack that never registers, or never creates, `sys_team_member`, `sys_user_position` or `sys_member` keeps answering no holders through it. That is judged by the new `isObjectAbsentError(error, readObject)` in `@objectstack/types`: the shared missing-table predicate for that object, or the engine's `OBJECT_NOT_FOUND` naming that object. Any other error, including an absence of a different object, stays loud.
26+
27+
**The remedy.** None is needed. A refusal means the membership table could not be read: fix that read, and the next expansion succeeds. A failed read is never cached.

‎content/docs/automation/approvals.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,9 @@ What an entry that resolves to nobody leaves on the slate depends on its kind:
455455
decide it (the admin-override callout under
456456
[Acting on requests in the console](#acting-on-requests-in-the-console)).
457457
Beside entries that do resolve, a literal is a slot like any other, so a
458-
`behavior` that needs every slot (`unanimous`) waits on it too. A `position` or
459-
`org_membership_level` entry whose `sys_member` read failed keeps its literal
458+
`behavior` that needs every slot (`unanimous`) waits on it too. A `team`,
459+
`position` or `org_membership_level` entry whose holder read failed
460+
(`sys_team_member`, `sys_user_position` or `sys_member`) keeps its literal
460461
the same way. It logs `could not be expanded — a directory or read-filter
461462
lookup failed` with the `error`, instead of `expanded to nobody`, and never
462463
routes to the holders that happened to read.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formul
2121
// writer-local re-derivation here was rejected by name (Option B): it would be
2222
// a third answer to a question the codebase already answered two ways.
2323
import { createRecordOrganizationResolver, type RecordOrganizationResolver } from '@objectstack/metadata-core';
24-
import { isMissingTableError, keysetWalk, strandedDecisionFailure } from '@objectstack/types';
24+
import { isObjectAbsentError, keysetWalk, strandedDecisionFailure } from '@objectstack/types';
2525
// [#15981 / #16166] Every built-in identity NAME is deliberately absent here:
2626
// both arms of `isOverrideActor` read an ADR-0095 capability rung, never a name.
2727
// `BUILTIN_IDENTITY_PLATFORM_ADMIN` went with the platform arm's name read and
@@ -1119,29 +1119,13 @@ const OOO_MAX_CHAIN = 8;
11191119
const MEMBER_SCREEN_READ_LIMIT = 50000;
11201120

11211121
/**
1122-
* [#29362] Rows per page of the membership-tier read
1123-
* ({@link ApprovalService.expandMembershipTierUsers}) — the bound the read
1124-
* always carried, so a tier held by fewer people issues the one read it always
1125-
* did, and a tier held by more is read in further pages rather than cut.
1122+
* [#29362, #29503] Rows per page of the directory holder reads
1123+
* ({@link ApprovalService.readHoldersToCompletion}: the membership tier, the
1124+
* position assignments, the team members) — the bound each read always
1125+
* carried, so a group held by fewer people issues the one read it always did,
1126+
* and one held by more is read in further pages rather than cut.
11261127
*/
1127-
const MEMBER_TIER_PAGE_ROWS = 10000;
1128-
1129-
/**
1130-
* [#29362] Did a `sys_member` read fail because this stack has no membership
1131-
* object at all — the one failure whose empty answer is the truth? Two
1132-
* spellings of the same absence, each attributed to `sys_member` so a failure
1133-
* about another object is not evidence about this one: the driver's
1134-
* missing-table refusal (the shared `isMissingTableError`, the table
1135-
* registered but never created), and the engine's `OBJECT_NOT_FOUND` (a
1136-
* composition that never registers the object, refused before any driver is
1137-
* asked — the reading `sys-metadata-repository.ts` gives its own history
1138-
* object). Anything else is an outage and stays loud.
1139-
*/
1140-
function membershipObjectAbsent(err: unknown): boolean {
1141-
if (isMissingTableError(err, 'sys_member')) return true;
1142-
const refused = err as { code?: unknown; object?: unknown } | null | undefined;
1143-
return refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_member';
1144-
}
1128+
const HOLDER_PAGE_ROWS = 10000;
11451129

11461130
/**
11471131
* Approver types resolved by QUERYING a graph rather than by taking `value`
@@ -2935,16 +2919,12 @@ export class ApprovalService implements IApprovalService {
29352919
private async expandTeamUsers(teamId: string, organizationId?: string | null): Promise<string[]> {
29362920
if (!teamId) return [];
29372921
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
2938-
let rows: any[] = [];
2939-
try {
2940-
rows = await this.engine.find('sys_team_member', {
2941-
where: { team_id: teamId },
2942-
fields: ['user_id'],
2943-
limit: 10000,
2944-
context: SYSTEM_CTX,
2945-
} as any);
2946-
} catch { rows = []; }
2947-
const users = Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
2922+
// [#29503] Every member, read to completion — or a refusal that
2923+
// {@link resolveApproverSpec}'s directory catch reports as a failed read,
2924+
// never "expanded to nobody". A stack with no `sys_team_member` has none.
2925+
const users = await this.readHoldersToCompletion(
2926+
'sys_team_member', { team_id: teamId }, `the members of team '${teamId}'`,
2927+
);
29482928
// #10547: the TEAM proved its tenancy above; its members have not proved
29492929
// theirs, and `sys_team_member` holds no fact that could. One read.
29502930
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
@@ -3341,18 +3321,16 @@ export class ApprovalService implements IApprovalService {
33413321
*/
33423322
private async expandPositionUsers(positionName: string, organizationId?: string | null): Promise<string[]> {
33433323
if (!positionName) return [];
3344-
const users = new Set<string>();
3345-
const filter: any = { position: positionName };
3324+
const filter: Record<string, unknown> = { position: positionName };
33463325
if (organizationId) filter.organization_id = organizationId;
3347-
try {
3348-
const rows = await this.engine.find('sys_user_position', {
3349-
filter, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX,
3350-
} as any);
3351-
for (const r of (rows ?? []) as any[]) {
3352-
const uid = String(r.user_id ?? '');
3353-
if (uid) users.add(uid);
3354-
}
3355-
} catch { /* table may not exist on minimal stacks — union source below still applies */ }
3326+
// [#29503] Every assignment, read to completion — or a refusal, never the
3327+
// tier limb below alone (a shrunk slate that looks complete). A stack with
3328+
// no `sys_user_position` has no assignment holders; the union source below
3329+
// still applies.
3330+
const users = new Set<string>(await this.readHoldersToCompletion(
3331+
'sys_user_position', filter,
3332+
`the assignments of position '${positionName}'${organizationId ? ` in organization '${organizationId}'` : ''}`,
3333+
));
33563334
// ADR-0057 D4 transition source: pre-migration stacks still carry the
33573335
// position name in better-auth's `sys_member.role` column, so the same
33583336
// lookup serves a position name here and a membership tier for
@@ -3440,7 +3418,7 @@ export class ApprovalService implements IApprovalService {
34403418
* change here changes position routing too.
34413419
*
34423420
* [#29362] Read to COMPLETION, or refused — never a shortened or an invented
3443-
* empty slate. Pages of {@link MEMBER_TIER_PAGE_ROWS} through the platform's
3421+
* empty slate. Pages of {@link HOLDER_PAGE_ROWS} through the platform's
34443422
* one keyset walk (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a
34453423
* tier held past the bound routes to every holder rather than the first
34463424
* 10,000 rows. A read that fails, or a walk that cannot vouch for completion
@@ -3452,17 +3430,40 @@ export class ApprovalService implements IApprovalService {
34523430
* nobody", indistinguishable from a tier no one holds. A membership object
34533431
* that does not exist is the one failure that IS an answer (a stack that never
34543432
* materialises `sys_member` has no tier holders) and keeps answering `[]`;
3455-
* see {@link membershipObjectAbsent}.
3433+
* see {@link readHoldersToCompletion}.
34563434
*/
34573435
private async expandMembershipTierUsers(tier: string, organizationId?: string | null): Promise<string[]> {
34583436
if (!tier) return [];
34593437
const filter: Record<string, unknown> = { role: tier };
34603438
if (organizationId) filter.organization_id = organizationId;
3461-
const scope = `the holders of membership tier '${tier}'${organizationId ? ` in organization '${organizationId}'` : ''}`;
3439+
return this.readHoldersToCompletion(
3440+
'sys_member', filter,
3441+
`the holders of membership tier '${tier}'${organizationId ? ` in organization '${organizationId}'` : ''}`,
3442+
);
3443+
}
3444+
3445+
/**
3446+
* [#29362, #29503] The `user_id`s of every `object` row matching `where`,
3447+
* read to COMPLETION — or a refusal, never a shortened or an invented empty
3448+
* slate. The one read behind the membership tier, the position assignments
3449+
* and the team members.
3450+
*
3451+
* Pages of {@link HOLDER_PAGE_ROWS} through the platform's one keyset walk
3452+
* (`keysetWalk`, `@objectstack/types`), ordered by `id`, so a group held past
3453+
* the bound routes to every holder rather than the first 10,000 rows. A read
3454+
* that fails, or a walk that cannot vouch for completion (a row without an
3455+
* `id`, a page that did not honour the cursor), THROWS:
3456+
* {@link resolveApproverSpec}'s directory catch then lets the slot fall back
3457+
* to its literal and says the read failed. An object that does not exist on
3458+
* this stack is the one failure that IS an answer (no holders through it) and
3459+
* answers `[]` — `isObjectAbsentError` (`@objectstack/types`) decides that,
3460+
* attributed to `object`, so an absence of another object is still loud.
3461+
*/
3462+
private async readHoldersToCompletion(object: string, where: Record<string, unknown>, scope: string): Promise<string[]> {
34623463
const users = new Set<string>();
34633464
const walk = keysetWalk<Record<string, unknown>>(
3464-
(page) => this.engine.find('sys_member', { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }),
3465-
{ where: filter, pageSize: MEMBER_TIER_PAGE_ROWS },
3465+
(page) => this.engine.find(object, { ...page, fields: ['id', 'user_id'], context: SYSTEM_CTX }),
3466+
{ where, pageSize: HOLDER_PAGE_ROWS },
34663467
);
34673468
try {
34683469
for await (const page of walk.pages()) {
@@ -3472,7 +3473,7 @@ export class ApprovalService implements IApprovalService {
34723473
}
34733474
}
34743475
} catch (err) {
3475-
if (membershipObjectAbsent(err)) return [];
3476+
if (isObjectAbsentError(err, object)) return [];
34763477
throw Object.assign(new Error(
34773478
`[approvals] ${scope} could not be read: ${err instanceof Error ? err.message : String(err)}. `
34783479
+ 'Routing to a shortened slate would leave holders unasked, so the expansion refuses instead.',

0 commit comments

Comments
 (0)