# ADR 0038: Member Group Management And Resolution ## Status Accepted. ## Context ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the principal-set permission model. The file library (《文件库-接口契约.md》) computes effective permission over two principal kinds — `USER` and `GROUP` — and consumes the group side through a single read-only port, `GroupResolver` (`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2). The contract's v0.1 proposal framed the Group system as a *separate HTTP service* owned by another team, consumed read-only. In practice the schema now carries the group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`, `MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and the product requirement is to build **group management in the backend admin**, not to integrate a foreign service. Until this ADR, nothing read or wrote those tables: the live `GroupResolver` was a transitional implementation reading flat hub `Team` membership, and the admin "Group 管理" panel actually managed `Team`. This ADR settles the semantics needed to make the `MemberGroup` tables the real, in-hub group system. ## Decision ### Group system is in-hub, not a foreign service `MemberGroup` is the platform's global member-group principal. It lives in the hub database and is managed through the `/database` backend. The contract's "separate service" framing was an unfrozen v0.1 proposal; the implementation aligns to the tables that were actually built. The `GroupResolver` port stays — an external `HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the default implementation reads the in-hub `MemberGroup` closure. ### Authority: website administrator only Group create/delete and member add/remove are restricted to the **website administrator**, defined (consistently with the rest of the file library, D19/C4 adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in `filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform control plane; the file library uniformly uses org OWNER/ADMIN today and this feature stays consistent with that. Reading groups for the authorization selector (`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage holder's ability, not an administrator's. ### Resolution semantics (the crux) `resolveMemberGroupIds(user)` returns the user's **active direct groups ∪ the active ancestors of those groups**, deduplicated (the closure's depth-0 self row makes each direct group its own ancestor). This is the single query the permission engine relies on; equivalently: a grant placed on group G applies to members of G and of every descendant of G (requirement 3.2 — permission flows down the tree, so resolution collects up the tree). It is computed **live, never cached** (contract D4/G4): a membership change is visible on the very next protected request. MemberGroup is global (no `organizationId`), so resolution is not org-scoped. ### Soft delete via `archivedAt`, cascading the subtree Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where `ancestorId = G`, stamp `archivedAt` on each active descendant) — an application operation, not a DB constraint. Closure and membership rows are **retained**; resolution and listing filter by `archivedAt`, so an archived group and everything under it stop contributing to permission at once. ### Closure maintenance The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P` insert `(a.ancestorId, G, a.depth + 1)` for every `a` in `closure where descendantId = P`. v1 does **not** support reparenting a group (moving it under a new parent). The schema reserves reparent (closure rebuild plus the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on. ### Rename and description edits are in scope; reparent stays out A group's `name` and `description` are mutable by the website administrator (`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately separated from reparent: renaming touches **no** closure row and cannot create a cycle, so it carries none of the invariant risk that keeps reparent out of v1. The endpoint therefore **rejects** a `parentId` field outright rather than ignoring it, so a future reparent cannot arrive silently through this route. Passing an empty `description` clears it; omitting a field leaves it unchanged. ### Restore is deliberately asymmetric with delete Archived groups stay visible to the administrator (`GET /database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the console tags and greys them) and can be restored (`POST /database/api/groups/:id/restore`, audited as `group.restore`). Restore is **not** the mirror image of delete. Delete cascades down the whole subtree; restore un-archives **the group plus every archived ancestor of it, and nothing below it**: - Restoring the ancestor chain is **mandatory**, not a convenience. An active group whose parent is archived has no path in the tree, and the `depth` derivation (closure row count) presumes "an active group's ancestors are active" — the invariant that cascade-delete establishes. Restoring a node alone would break it. - The subtree is deliberately **left archived**. A group's descendants may have been archived for reasons of their own, and one click should not silently re-grant permission across a whole historical branch. Descendants remain visible in their archived state and are each restored explicitly. Restore takes effect immediately, like every other membership change (D4/G4): the group resumes contributing permission on the next resolution. An archived group is **readable but not writable**. Its membership rows are never revoked by archiving, so `listMembers` succeeds on an archived group — the console must be able to show *who was in it* before deciding whether to restore it. Every mutation, by contrast, still requires an active group (`requireActiveGroup` → 404): rename, child creation, and member add/remove all reject. The group is inert for permission purposes and frozen for editing, but not hidden and not forgotten. ### Member picker reads global users, admin-only `GET /database/api/users/search` backs the "add member" picker: it matches `User` by display name or Feishu open id and is gated to the website administrator, the same authority that may add members. It widens no existing capability — adding a member already accepts **any** global user (`resolveUser` does not require an org membership), so the endpoint only replaces blind id entry with search. It is deliberately **not** opened to the non-admin authorization-selector audience that `/groups/search` serves: choosing a group to grant is a Manage-holder action, whereas enumerating people is not. `excludeGroupId` filters out the target group's active members so the picker cannot surface a candidate that must 409. ### Audit is written in-hub The contract (C3 §6.3) originally deferred group actions to the foreign Group service's own audit. With the group system in-hub, group mutations are audited through the existing file-library sink (`filelib/audit.ts`, same-transaction `AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`, so the audit row is attributed to the silo org. New actions: `group.create`, `group.update`, `group.delete`, `group.restore`, `group.member_add`, `group.member_remove`; new audit object type `group`. ## Consequences - The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader. `createTeamGroupResolver` is retained but deprecated (no longer wired); existing flat-Team group grants no longer resolve for the file library. - Group grants take effect in real time through the existing `effectiveRole` reducer (P6) with no change to the permission algebra — only the set of group ids fed to it changes. - v1 omits reparent; the closure invariants above must hold whenever reparent is added later (rebuild descendants' ancestor rows, reject cycles). - Group management is an admin-only surface; the authorization selector is not. - Numeric limits (max depth, max members) and a hard-delete/restore path remain follow-on operational decisions; they must not weaken the archived-filter, admin-authority, or live-resolution invariants fixed here.