You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit bbb98c4
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-sharing)!: a failed team-graph manager read refuses instead of answering no manager (#29836)
<!-- claude-projects-attribution: {"github_login":"os-warren"} -->
_Requested by **Warren** · [project
thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ87Fk1F3zNZJFiNofw8PYfxN)_Fixes#29792
domain:server#1 · os-warren · session_01JQwxbXvyuTnWc7sv5i5DBw
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: not needed. No skill and no `content/docs` page names
`TeamGraphService.managerOf` or `expandPrincipal`, or says what either
answers on a failed read.
## 维护者速读
- 改了什么:`TeamGraphService.managerOf`(查用户的经理)读 `sys_user`
失败时不再答「没有经理」,而是明确报错,且失败结果不再缓存一整轮;对象或表不存在仍答
`null`。报错文案新增「lookup」一类,单个负责人/经理的查询不再说成「持有人集合」。`packages/spec`
里两个接口方法的注释从「best-effort」改为「读失败即报错;没有则为 null」。
- 为什么改:#29647、#29703、#29787 已让 `plugin-sharing`
里决定授权的读在故障时报错;这是同族最后一处仍把故障吞成 `null`
并缓存到本轮结束的读,读恢复后经理仍是空的。今天没有调用方,但第一个按「记录所有者的经理」授权或派审批的规则会在故障期间悄悄漏掉经理。
- 风险与回滚:三个仓里都没有生产调用方(`expandPrincipal`
无调用方,`BusinessUnitGraphService.managerOf`
接线分支也无调用方,已逐一追踪),故本版运行行为不变;唯一「从答 `null` 变成报错」的是导出类或函数的外部调用方在读失败时,属收窄,标题带
`!`。`sys_member` 组织筛选的读失败放行(#10153 裁决)不变。回滚即 revert 本 PR。
- 先看:`packages/plugins/plugin-sharing/src/team-graph.ts`(`managerOf` 与
`holderReadRefusal`)、`packages/plugins/plugin-sharing/src/business-unit-head-manager-reads.test.ts`、`packages/spec/src/contracts/sharing-service.ts`(仅注释)。
- 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run
src/business-unit-head-manager-reads.test.ts`,预期 18/18
通过(读失败报错、恢复后答真值、`expandPrincipal` 一并报错、对象缺失答 null、组织筛选仍放行)。
- 需要你做的:无,席位落地。
## What changes
`TeamGraphService.managerOf`
(`packages/plugins/plugin-sharing/src/team-graph.ts`) reads `sys_user`
through `readMembershipToCompletion`, the walk #29787 gave
`BusinessUnitGraphService.headOf` and the standalone `managerOf`. No new
helper.
- **A failed read refuses.** It throws `[sharing] the manager of user
'ID' could not be read: …`, with the driver error as `cause`. It used to
answer `null` (no manager) and cache that `null` under `org::userId` for
the pass.
- **Nothing is cached after a failed read.** The throw comes before the
cache `set`, so the next call on the same pass reads again. A healthy
answer, a manager or `null`, is still cached as before.
- **Absence is still nobody.** A stack that never registers or never
creates `sys_user` answers `null` (`isObjectAbsentError`, through the
walk).
- **The organization screen is unchanged.**
`managerIsProvablyOutsideOrg` keeps its ruled fail-open posture on
`sys_member` (#10153). A control pin holds it.
- **S4, a `lookup` arm.** `holderReadRefusal` takes a third kind,
`lookup`, for a single head or manager. Its closing sentence says
answering "none" would drop the person it names, instead of speaking of
a holder set or an expansion. `TeamGraphService.managerOf`,
`BusinessUnitGraphService.headOf` and the standalone
`BusinessUnitGraphService.managerOf` all read as `lookup`. The
`expansion` and `check` sentences are byte-identical.
- **S1, the contract prose.** In
`packages/spec/src/contracts/sharing-service.ts`, the JSDoc of
`ITeamGraphService.managerOf` and `IBusinessUnitGraphService.headOf`
reads "refuses (throws) on a failed read; `null` when none" instead of
"best-effort". JSDoc only: no key, type or signature changes
(`check:api-surface` green). It ships in `dist/contracts/index.d.ts`, so
`@objectstack/spec` takes a `patch`.
- No `protocol.ts`, no new export, no metadata change.
### Measured first (real engine, before and after)
A scratch probe on ObjectQL + SqlDriver over in-memory SQLite, with the
shipped identity objects, ran on `main` (`eb05ea2f`) and on the fix
(`872978b4`). One `sys_user` read failed, then the same instance was
called again. The probe's users are `alice`, managed by `bob`.
| Reading | `main` | this PR |
|:--|:--|:--|
| healthy read | `bob` | `bob` |
| failed read | `null` | throws `the manager of user 'alice' could not
be read: SQLITE_IOERR…` |
| same instance, after the read recovers | `null` (the failed answer was
cached) | `bob` |
| a new instance, after recovery | `bob` | `bob` |
| `sys_user` not registered | `null` | `null` |
| `sys_user` table dropped | `null` | `null` |
The card's premise holds: the failed answer was cached under
`org::userId` for the pass.
### Callers (traced in objectstack, hotcrm and docs.objectos.ai)
| Caller | Where | What it sees on a refusal |
|:--|:--|:--|
| `expandPrincipal` `manager` arm | `team-graph.ts`, exported from the
package index | the refusal propagates. It has no caller in any of the
three repositories |
| `BusinessUnitGraphService.managerOf`, wired limb |
`business-unit-graph.ts` | passes the refusal through. The method has no
production caller (#29787 trace) |
| Sharing rules (`SharingRuleService`) | builds a `TeamGraphService` |
calls `expandUsers` / `expandRoleUsers` only: nothing changes |
| Ownership queues (`ownership-queue-service.ts`) | builds a
`TeamGraphService` | `expandUsers` / `expandRoleUsers` only: nothing
changes |
| Positions (`PositionGraphService`) | holds a `TeamGraphService` |
`holdsRole` / role reads only: nothing changes |
| Approvals (`plugin-approvals`) | its own `lookupManager` | does not
call `TeamGraphService.managerOf`: nothing changes |
hotcrm and docs.objectos.ai have no reference to `managerOf`,
`expandPrincipal` or `TeamGraphService` at their `origin/main`
(`d4ffe99`, `4a00661`). No caller fails a whole pass where it used to
drop one person, so the STOP condition did not trip.
## Tests
`src/business-unit-head-manager-reads.test.ts` is the shared real-engine
harness for these lookups, so the new pins go into it as a top-level
`describe`. It now has 18 tests.
New, `[#29792] TeamGraphService.managerOf`:
| Pin | Asserts |
|:--|:--|
| control: healthy read | the manager; a user without one answers `null`
|
| ⛔ failed read refuses | throws, names the user; `cause.code` is the
driver's `SQLITE_IOERR` |
| ⛔ not cached | the same instance answers `usr_bob` after one failed
read |
| ⛔ `expandPrincipal` manager arm | `[usr_bob]` when healthy; refuses
when the read fails |
| absence: object never registered | `null` |
| absence: table never created | `null` |
| control: organization screen | an unreadable `sys_member` keeps the
manager |
**S3.** The `[#29713] CONTROL` pin no longer hard-codes `toBeNull()` for
the wired limb on a failed read. Its equality control stays: the wired
limb's settled outcome (answer or refusal message) equals
`TeamGraphService.managerOf`'s, on a healthy and on a failed read.
The refusal sentence's wording (S4) is not pinned: no consumer parses
it. The existing pins match the first clause, which did not change.
- `pnpm --filter @objectstack/plugin-sharing test` at `872978b4`: 65
files / 1387 tests passed.
- `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0 (its
`check:test-typecheck` compiles the test layer).
- `pnpm --filter @objectstack/spec typecheck`: exit 0. `pnpm --filter
@objectstack/spec check:generated`: exit 0, all 14 artifacts current.
<details><summary>Ablations: each negative pin's behaviour put back
(red), restored by ablation-replace (blob == HEAD, git diff HEAD
empty)</summary>
The test imports the subject from `src/` by relative path, so no build
sits between the mutation and the run.
A1, refusal: `.catch(() => null)` put back on the `sys_user` read in
`TeamGraphService.managerOf`
```
ablation-replace: anchor x1 -> x0
ablation-replace: blob 611685b -> 8f45c95f20455b37cc4fdf8f539f8a1527eee57b
× ⛔ a failed read refuses — it never answers an invented null 16ms
× ⛔ a failed read is not cached: the next read on the same pass answers the real manager 13ms
× ⛔ expandPrincipal's manager arm refuses with it, rather than answering nobody 16ms
Tests 3 failed | 15 passed (18)
ablation-replace: ok restored: blob == HEAD (611685b) and `git diff HEAD` is empty
```
A2, cache: the read still throws, but caches `null` for the key first
```
ablation-replace: anchor x1 -> x0
ablation-replace: blob 611685b -> e2a786a9ddc37a2ac4410452259c5c4da16ea26b
× ⛔ a failed read is not cached: the next read on the same pass answers the real manager 16ms
Tests 1 failed | 17 passed (18)
ablation-replace: ok restored: blob == HEAD (611685b) and `git diff HEAD` is empty
```
A3, `expandPrincipal`: `.catch(() => null)` on the manager arm's
`managerOf` call
```
ablation-replace: anchor x1 -> x0
ablation-replace: blob 611685b -> 33b2407d944b8883cc8878b0ca462b650cb904de
× ⛔ expandPrincipal's manager arm refuses with it, rather than answering nobody 24ms
Tests 1 failed | 17 passed (18)
ablation-replace: ok restored: blob == HEAD (611685b) and `git diff HEAD` is empty
```
</details>
## Acceptance notes
- `expandPrincipal`'s `manager` arm now refuses with the lookup instead
of answering `[]`. The function is exported and has no caller in the
three repositories, so nothing on this release changes. A future caller
decides per pass what a refused principal means.
- `managerIsProvablyOutsideOrg` still answers "not outside" when
`sys_member` cannot be read, by the #10153 ruling. This PR keeps it and
pins it.
## Verification
Readings at `0609359` (the fix `872978b4` plus a merge of `main` at
`b62463f1`) unless named. The merge brought no change to
`plugin-sharing`; the derived gate list is identical before and after
it.
- `node scripts/pm/dispatch-gates.mjs --commands`: 82 derived families,
run at `872978b4` and derived again, identical, at `0609359`. `--ran`
reconciliation exit 0: "82 derived, 82 run, 0 NOT-MEASURED". Three first
exited 3 on prerequisites, not on the diff: the
`check-plugin-teardown-shape` self-test on a shallow clone, and
`check:dual-build-cjs-loads` and `check:i18n` before the packages had a
`dist/`. All three passed at `0609359` after `git fetch --unshallow
origin main` and a full `pnpm build` (117/117 tasks).
- `pnpm lint`: exit 0 (at `872978b4`).
- `pnpm --filter @objectstack/spec check:generated`: exit 0. The JSDoc
is present in `packages/spec/dist/contracts/index.d.ts` and `.d.mts`
after the build, which is why `@objectstack/spec` takes a `patch`.
- Dogfood battery (the claim carries a `(narrowing)` arm): `0609359` ·
`os-verify-lock: VERDICT command-exit 0 · held the lock 1439s` · Test
Files 405 passed, 3 skipped (408) · Tests 3352 passed, 28 skipped (3380)
· no failures.
- Every family CI runs is cited from this PR's check-runs at landing,
not re-run here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JQwxbXvyuTnWc7sv5i5DBw)_
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-sharing)!: a failed team-graph manager read refuses instead of answering no manager
7
+
8
+
Clause-②: no (narrowing)
9
+
Metadata-protocol: none
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no metadata changes: the lookup reads the row it already read, and refuses a read that already failed. The spec change is interface documentation only. No author has anything to rewrite. -->
12
+
13
+
**BREAKING** only where the read fails: `TeamGraphService.managerOf` used to answer `null` then, and now refuses.
14
+
15
+
The holder reads of `TeamGraphService`, and the head and standalone manager lookups of `BusinessUnitGraphService`, already refuse on a failed read. This was the last lookup of that family that did not.
16
+
17
+
-**`TeamGraphService.managerOf(userId, organizationId)`.** A failed `sys_user` read now throws. Before, it answered `null` (no manager) and kept that answer for the rest of the pass, so the manager stayed missing after the read recovered. Nothing is cached after a failed read now: the next call reads again.
18
+
-**Who sees it.** The `manager` arm of the exported `expandPrincipal`, and `BusinessUnitGraphService.managerOf` when it is built with a `teamGraph`, pass the refusal through instead of answering no manager.
19
+
-**A missing object is still nobody.** A stack that never registers, or never creates, `sys_user` keeps answering `null`. That is judged by `isObjectAbsentError`; any other error stays loud.
20
+
-**The organization screen is unchanged.** When `sys_member` cannot be read, the manager is still kept, as before.
21
+
-**The refusal names a lookup.** A failed head or manager lookup now says it is a lookup of one person, not a holder set or an expansion.
22
+
-**The contract says so.** The documentation of `ITeamGraphService.managerOf` and `IBusinessUnitGraphService.headOf` in `@objectstack/spec` now reads "refuses (throws) on a failed read; `null` when none" instead of "best-effort". No key or type changes.
23
+
-**No caller on this release changes behaviour.** No sharing rule, approval or ownership queue on this release calls `managerOf` or `expandPrincipal`. A caller of the exported class or function sees the refusal instead of `null`.
24
+
25
+
**The remedy.** None is needed. A refusal means the user table could not be read: fix that read, and the next lookup succeeds.
0 commit comments