--- title: "ADR 0023 — Share a doc or a folder with people and groups" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:42:00.328Z" --- # ADR 0023 — Share a doc or a folder with people and groups **Status** Accepted, amended by [ADR 0026](./0026-two-visibility-levels.md) (two Share levels, Open and Private, and no agents box; §1 and §2) · **Date** 2026-09-16 · **Amends** [ADR 0020](./0020-restricted-spaces.md) §2, §5 and §6 · **Relates to** ADR 0009 (the visibility ladder), ADR 0008 (the briefing cache), ADR 0016 (what an agent may write), [`../TAXONOMY.md`](../TAXONOMY.md) §9, [`../../design/screens/sharing.md`](../../design/screens/sharing.md) (the Share dialog), `packages/core/src/permissions/access.ts`, `share.ts` and `groups.ts` (the model), `apps/worker/src/routes/api/share.ts` and `groups.ts` (the doors) ## Context ADR 0020 shipped one slice of sharing: an owner puts an allow-list in a folder's `_folder.md`. The owner then asked for four more things: 1. Share one doc, not only a folder. 2. Let the people who work on a doc share it. An owner is not always there. 3. Share with a named group of people, not only with each person. 4. Ask who can open a doc when the doc is made, not after. ADR 0020 named item 1 as a revisit trigger. Its `team:` entries matched a team by name, so a rename closed the team's docs. The team pages stayed behind a flag, and no test made a team through the Worker. ## Decision ### 1. A doc can hold its own list (amends ADR 0020 §2) `access:` is valid in any doc, and in any folder's `_folder.md`. It is never valid in the top-level `_folder.md`. - An entry is a member email address, or `group:` and a group address. The ADR 0020 spelling `team:` reads as the same entry. - A list holds 200 entries at most. A longer list names a group instead. - A person who is not an owner opens a doc only when all three are true: the person passes every folder list on the path, the person passes the doc's own list, and the ladder allows the doc. - `access: []` means owners only. A value that does not parse reads as owners only. The web app shows three levels and one checkbox. `packages/core/src/permissions/share.ts` maps them to the header, and both the web app and the Worker call it: | Level | What it writes | | --- | --- | | Everyone, or Same as the folder | No `access:` key | | Only people you choose | `access:` with the chosen entries | | Only owners | `visibility: private`, and no `access:` key | The checkbox "Connected agents can read it" writes `visibility: sealed` when cleared. When checked, it removes the item's own `visibility:` key. It never writes `visibility: open`. ### 2. Anyone who can open and edit an item may share it (amends ADR 0020 §5) `refuseAccessChange` replaces the owner-only rule with these rules: 1. An agent may never change `access:`. A repository pull writes at the owner ceiling, as before. 2. No actor may set `access:` in the top-level `_folder.md`. 3. A person who is not an owner must pass the item's lists after the write. Otherwise the change is refused as a self-lockout, with cause and fix. 4. A person who is not an owner must pass the item's lists before the write. Otherwise the answer is the note-not-found refusal (ADR 0020 §4, now for the doc's own list too). 5. `visibility: private` stays owner-only. Nothing else is limited. A member who can open an item can already copy it, so a rule against widening protects nothing. A person outside the list cannot add themselves, because the item answers 404 to them. Owners always pass, and the history can undo a change. A narrowing is visible: the dialog says how many members lose access, and the Worker logs `share_changed` and `note_access_changed` at `warn`. ### 3. A group has a fixed address (amends ADR 0020 §6) A group is a better-auth `team`. "Group" is the product word, and `team` stays the storage name. - kmem keeps the address in its own table, `group_handle`, with one row per team and a unique address per org. - At creation, the address comes from the name through the one slug rule. When it is taken, the Worker adds `-2` to `-99`. When nothing is left, the address is `g-` and the team id. - A rename changes the name only. The address never changes, so every list keeps working. - A delete removes the address row. A list entry that names the old address then matches nobody. A new group with the same address gets that access. The delete dialog says so. - `/api/groups` is the only door that makes or changes a group. The Worker refuses better-auth's own team writes with `group-door`. Only owners and admins change groups (`administersOrg`). ### 4. Storage and reads Migration `0025_restricted_docs_and_group_handles.sql` adds `notes.restricted`, a partial index on it, and the `group_handle` table. - `CommitService` writes `notes.restricted` from the same parse as the rest of the row. - For a person who is not an owner, the read scope reads the restricted docs through the partial index, and the person's group addresses. The denied doc ids go into every read surface that already honours a folder denial (invariant I7). - A connection reads with its creator's denial. The Quick Connect cache key changes with it. - Vectorize is not filtered. A denied chunk can take a dense slot and is then dropped, as in ADR 0020. ## Consequences - One dialog shares docs and folders. The create form asks the same question. - A doc under a folder list can narrow that list. It cannot widen it. - Every member request that is not an owner's reads the restricted docs and the group rows. The partial index keeps this small. - The Members page no longer holds teams. Settings, Groups holds them. ## Options rejected - **Keep sharing owner-only.** An owner is not always there, and the rule protected nothing that a member cannot copy. - **Rewrite every list when a group is renamed.** One rename would write every doc that names the group, and a failure in the middle would leave lists that disagree. - **Put group ids in the Markdown.** An id is not readable in an editor (rule 1 of `AGENTS.md`). - **Add a column to better-auth's `team` table.** The plugin owns that table and its migrations. - **Scan every doc header on every request.** On a 4,000-doc org that reads 4,000 rows per request. ## What would make me revisit this - A request to limit narrowing to owners and admins. That is one branch in `refuseAccessChange`. - Per-person levels (read, suggest, edit) on a doc. - More than a few hundred restricted docs in one org, which makes the per-request read large.