--- title: "ADR 0020 — A restricted space names who may open it, and a connection follows its person" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:41:17.320Z" --- # ADR 0020 — A restricted space names who may open it, and a connection follows its person **Status** Accepted, amended by [ADR 0023](./0023-sharing-people-and-groups.md) (doc lists, who may share, and groups) and by [ADR 0026](./0026-two-visibility-levels.md) (a connection reads what its person reads, with no reach of its own; §1 and §3) · **Date** 2026-09-16 · **Supersedes** nothing · **Amends** ADR 0009 through its revisit trigger "a second human principal needing a different answer" · **Relates to** ADR 0009 (the visibility ladder and the read scope), ADR 0008 (the briefing cache), ADR 0010 (a control needs a record that shows it stopped), ADR 0016 and ADR 0017 (what an assistant may write), [`../TAXONOMY.md`](../TAXONOMY.md) §2 and §7, [`../../design/screens/sharing.md`](../../design/screens/sharing.md) (the folder access dialog), `packages/core/src/permissions/access.ts` (the model), `apps/worker/src/services/permissions.ts` (where the model meets a request) ## Context The product had one rule for people: an owner reads everything, and a member reads everything except `private`. There was no way to say "only finance reads finance". The owner asked for exactly that, and for connections that "inherit the acting user's permissions". Two facts from the reconciliation of 2026-09-16 made the request urgent: 1. **A connection did not follow the person who made it.** `validateToken` checked the creator's membership only for a connection that OAuth issued. A token from Connect and a Quick Connect link kept working after their creator left the organization. The Connect wave added a `Never` expiry, so such a link could work for ever. 2. **"Inherit" held only by accident.** A link's read scope came from its `reach` alone. It read no fact about its creator. That was safe only while no person had a narrower answer than another. The first per-person rule would let a link read what its creator cannot. ADR 0009 rejected per-note access lists as premature (its option E) and left a seam: "the first real `invited` case brings back grants". This is that case. ## Decision ### 1. A connection follows its creator's membership Every connection names its creator in `session_tokens.created_by`: a token from Connect, a Quick Connect link and an OAuth connection. On every request, `validateToken` reads the creator's `member` row in the organization. No row means `revoked`, on the next request, whatever the expiry says. - The self-hosted project has no organization, so there is no membership to lose. The `user` row gives the role and the address. - A public-demo principal (`try:…`) has no account. It passes, as a member with no address. - The check gives the validated record a `creator`: the role, the address, and the folders the creator may not open (section 3). A connection reads with its creator's denials. When an owner removes a member, or a member leaves, the auth wrappers also revoke every live connection that person made in that organization, through `revokeToken`. Each one gets a `revoked_at` stamp and a `token_revoked` audit row, and the wrapper writes `org_member_links_revoked` at `warn`. The request-time check is the first safeguard. The stamp is the second, and it is the one a person sees in Connect. A person who rejoins gets no old connection back. `Never` expiry stays. With the two safeguards, a connection with no expiry ends when its person leaves. The Connect mint refuses a reach above `maxReachFor(ceilingForPerson(role))`. Both roles read above `escorted` today, so this refuses nobody yet. ### 2. The unit is a space, and the list is an allow-list A folder's `_folder.md` may carry `access:`, a list of the people who may open the folder: ```yaml --- access: - priya@acme.com - team:finance --- ``` - An entry is a member's email address, or `team:` and a team name. Both compare without case. - **Owners always pass.** A list never needs to name one, and no list can lock an owner out. - An `admin` is a member for this rule. Only the `owner` role passes every list. - **A nested folder takes the strictest list.** To open `finance/payroll`, a person passes the list on `finance` and the list on `finance/payroll`. A folder with no list adds no condition. - An empty list, `access: []`, means owners only. - `access:` has an effect only in a folder's `_folder.md`. `CommitService` refuses it in the top-level `_folder.md`, because membership already decides who reads the whole knowledge base. In an ordinary doc it has no effect. An allow-list and not a deny-list, because it is additive: a person added later to the organization sees nothing in a restricted space until somebody names them. ### 3. A person not on the list sees nothing For a member who fails a list, every doc under the folder is absent from every read surface. These are the surfaces: - `GET /api/notes`, Browse and the tree counts - search, and `recall` (lexical, dense and title-path) - backlinks and relations - Review, the per-doc review counts, the audit log and history - the usage panels and the verify queue - the sittings list, the worklog, the import-batch offer and the export - the briefing, and the briefing's "withheld" sentence A direct address answers 404. No count and no folder name reaches the person. The folder does not appear in the tree. A tree prints a node for the folder that the caller asks for by path, even when the folder holds nothing the caller can read. For a folder the caller may not open, that node shows what a folder with no policy file shows: no lock mark, and the level of the folders above it. `folderTreeFactsFor` gives the web tree, the Quick Connect `browse` and the MCP `browse` tool the same facts. Without it, a member could send `GET /api/tree?path=finance` and learn from the lock mark that `finance` is a restricted folder. The rule lives on the `ReadScope`, which every read in the Worker takes (invariant I7): - `readScopeForPerson` takes a `PersonAccess`, which only `principalAccessFor` makes. - `readScopeForLink` takes an `AuthenticatedLink`, which carries its creator's `PersonAccess`. A caller that never asked who the creator is does not compile. - `visibleTo` adds one clause, `notes.folder not in (select value from json_each(?))`, with the exact stored folder values the principal may not open. It binds **one** parameter, whatever the number of folders, so D1's limit of 100 bound parameters is not at risk. - `ReadScope.reaches` refuses an address under a denied folder with the same folded walk the visibility resolver uses (`authoredOnPath`), so `Finance/x` is under `finance`. - `inheritedReachFor`, `readablePermalinks` and `withheldFrom` apply the same denial. A connection keeps its own reach. An `open` link still reads only `open` docs. The creator's denials narrow it further, and nothing widens it. The in-app assistant and an in-page agent read with the signed-in person's denials in the same way. An anonymous reader of `/docs` is on no list, so the public scope carries every restricted folder. An operator who publishes `handbook` does not publish a restricted folder inside it. The per-note publication axis (`publication: public`, `routes/public/publication.ts`) mints no scope, so it applies the same denial itself. A doc inside a restricted folder is not on the web, whatever its own header says. ### 4. Writes into a restricted space look like a missing doc `CommitService` refuses a change to an address under a folder the actor may not open. For an agent, the actor is the person it acts for (`onBehalfOf`). The refusal is `note-not-found`, the same answer a person gets for a doc that is not there: - `CommitService` asks this question before any other refusal that depends on the address. A create at a taken address and a create at a free address get the same answer, so a create does not show that a doc exists. - `noLiveNote` holds the words of each missing-doc refusal, and a denied update, splice, delete or move gets those words exactly. - A create and a move's destination have no missing-doc refusal to copy. Their words say that no folder the caller can open holds the address. So when a member creates a doc inside a restricted folder, the refusal tells that member that the folder has a list. That is the same trade as the address probe that ADR 0009 accepted: a refusal names only the address the caller sent. - `CommitService` reports a `supersedes:` target inside a closed folder as missing, as it reports a target that is not there. The write that names it still lands. - The browser upload and folder ingest ask the same question before they probe the address. They refuse a closed address with one sentence that does not say whether a doc is there. - `remember` on the MCP and Quick Connect doors does not probe an address in a closed folder. It does not report a taken address there, and it does not choose `-2` in its place. The create goes to `CommitService`, which refuses it as above. ### 5. Only an owner changes a list, and never an assistant `refuseAccessChange` (`packages/core`) refuses any change to `access:` in a `_folder.md` unless the actor is a person with the owner role: - The rule refuses an assistant at every level, like every other permission key (ADR 0016). - The rule refuses a member, even a member on the list. Otherwise a member could add themselves, or lock every other member out of an open folder. - A repository pull (`source: "git"`) skips this rule and the rule in section 4. It carries what the connected repository holds, and a person who can push there already controls the Markdown. - The browser upload (`POST /api/import`) and folder ingest (`POST /api/ingest`) do **not** skip either rule, although they write with `source: "ingest"`, which is an operator door for the level ceiling. Any member may use them, and their actor is the person who ran them, with that person's role. Until 2026-09-16 they skipped both rules. A member could upload a doc into a folder they could not open, or upload a `_folder.md` that closed an open folder to every other member. - A person's change to a `_folder.md` that goes to Review gets this rule on the way in. At acceptance the actor is the reviewer, and an owner who accepts a whole upload does not read every file in it. After each change to a list, `CommitService` writes `folder_access_changed` at `warn`. The line names the policy file, the actor kind and role, the door, and the number of entries before and after. It never names an entry, because a list holds member addresses. The audit row on the `_folder.md` is the record a person reads, and this line is the record an operator reads. `CommitService` refuses a value that is not a list of entries, cause then fix. A value that reaches D1 by a hand edit and does not parse reads as owners only. A permission nobody can read fails closed. ### 6. Teams A `team:` entry matches the person's `team_member` rows in this organization, by team name. When somebody renames a team, the old name matches nothing, so its people lose access until an owner updates the list. That fails closed, and the dialog shows the entry as not in this org. The team pages stay behind `LAUNCH_FLAGS.teams`. The Worker suite resolves a `team:` entry against real `team` and `team_member` rows, but no test creates a team through the Worker's sign-in routes yet. Until one does, the dialog lists people only, and the model still accepts `team:` entries that a person writes by hand or through git. ### 7. A list moves before the docs it closes `POST /api/folders/move` moves at most 20 docs in one request, and it moves a folder's policy files last. For a `visibility:` policy, the docs at the destination resolve without it for a short time. For a list, that time is a leak: every member and every connection could read the moved docs, and a move that stops in the middle would leave them open. So a policy file that carries a list does not move: 1. Before the first doc moves, the move writes a copy of the policy file at the destination. 2. The docs move, in one request or in more than one. The original stays in place, so both lists apply at every moment. 3. The part that reaches the original gives the copy the original's latest bytes, then deletes the original. A move that stops leaves both folders closed. The route refuses a move of a restricted folder before it writes anything when the caller is not an owner, because only an owner may write a list. It also refuses a destination whose own policy file sets a different list. The cost is history. The copy is a new doc, and the versions of the original stay under its old address. A list above the moving folder does not move with it: the docs leave that list, and that is the act the person asked for. ### 8. The list lives in the Markdown `access:` is frontmatter in `_folder.md`, an ordinary doc written through `CommitService`. Rule 1: a rebuild from R2 and a git clone keep the list. There is no migration, no D1 table, no Vectorize metadata index and no reindex. The cost is privacy. Member addresses are in the Markdown, in `blobs/` history and in every export, and they stay there after a person leaves. An owner who does not want that uses a team entry. ## Consequences - Each authenticated agent request makes one more indexed lookup (the creator's `member` row), and each request reads the organization row once. A principal who fails a list costs one more query per request (the distinct folders), and only then. - A team lookup runs only when some list in the project names a team. - Dense retrieval does not filter by folder in Vectorize. A denied chunk still takes a dense slot, and the candidate join drops it. A member who fails the list of a large part of the corpus gets lower dense recall. The in-process eval corpus has no lists, so the eval gate does not change. - The Quick Connect briefing cache (ADR 0008) keys each entry by link and by denial. When an owner restricts a folder, a link whose creator fails the new list misses the old entry. - `docs/API.md` changes: the tree gains `restricted` on each folder. The Quick Connect `browse` tree carries the same fact. The MCP `browse` tool adds `restricted: true` to a restricted folder, and sends no key for other folders. - The review counts per doc (`pendingProposalCountsByTarget`) count the bound parameters of their scope filter, which grows by two for each withheld folder. With a flat reservation, a member who could not open one folder got a 500 for a folder page of 88 docs. - A folder move of a restricted folder writes one more commit for each list, and one delete. ## Options rejected - **A grant table in D1** (`TAXONOMY.md` §7). A rebuild from R2 would lose every grant, and the web's `AccessPanel` contract needs levels this slice does not have. It stays the next step for per-grantee levels. - **A deny-list.** A new member would see a restricted space until somebody remembered to deny them. - **Show a denied folder's name with a lock.** The name can be the secret ("layoffs-2026"). - **Widen a connection to everything its person reads.** ADR 0009 keeps `sealed` and `private` away from every machine, and `escorted` needs a deliberate link. A connection reads at its own reach. - **Check membership only when a request uses a connection, with no stamp.** The connection would work again if the person rejoined, and Connect would show it as active. ## What would make me revisit this - **Per-grantee levels** (read, suggest, edit) at a space or a doc. That is the grant table and the `/api/access/*` routes the `AccessPanel` already calls. - **More than about 20 restricted folders in one organization**, or lists of more than about 50 entries. The per-request resolution reads every policy file and every distinct folder. - **A request to restrict a single doc.** Today a doc moves into a restricted folder instead.