--- title: "ADR 0026 — Two visibility levels, and a connection reads what its person reads" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:42:27.790Z" --- # ADR 0026 — Two visibility levels, and a connection reads what its person reads **Status** Accepted · **Date** 2026-09-17 · **Amends** [ADR 0009](./0009-permission-model.md) §1, §3, §4 and §7, [ADR 0020](./0020-restricted-spaces.md) §1 and §3, and [ADR 0023](./0023-sharing-people-and-groups.md) §1 and §2 · **Relates to** ADR 0016 and ADR 0022 (what an agent may write), ADR 0015 (the public documentation), [`../TAXONOMY.md`](../TAXONOMY.md), [`../SECURITY.md`](../SECURITY.md), [`../../design/screens/sharing.md`](../../design/screens/sharing.md), `packages/core/src/permissions/visibility.ts`, `reach.ts`, `access.ts` and `share.ts` (the model), `apps/worker/src/services/permissions.ts` (where the model meets a request), `packages/db/migrations/0029_two_visibility_levels.sql` ## Context ADR 0009 gave a doc one of four levels: `open`, `escorted`, `sealed` and `private`. Two of them were about machines. `escorted` let an agent read a doc only through a link aimed at it. `sealed` kept every agent out, and let every member in. A connection carried a `reach` of `open` or `escorted`, and the Connect form told a person that an agent reads only the docs marked Open. ADR 0020 and ADR 0023 then gave people lists: `access:` on a doc or a folder, and a connection that reads with its creator's denials. After that, the product had two rules that each answered "who can read this": a level, and a list. The Share dialog showed three people choices and one agents checkbox, and the Connect form showed a statement about levels that a person had to translate. On 2026-09-17 the owner decided, in these words: > we don't have the concept of escorted or sealed anymore its just open or private where open is > visible to everyone and private is only for people/groups it is shared with. And when creating > the connection it doesn't matter because it can access all open and all private that the > connection owner has access to ## Decision ### 1. Two levels (amends ADR 0009 §1) | Level | Who reads it | | --- | --- | | `open` | Every member of the org, and every connection a member made | | `private` | Owners, and the people and groups on its list, and their connections | - **An item with a list is private, and a private item has a list.** `ownVisibilityOf` in `packages/core/src/permissions/access.ts` is the one rule. An item is private when its header carries `access:` or says `visibility: private`. `ownAccessListOf` beside it gives the list: the `access:` list, or the empty list for `visibility: private` alone. - **An empty list means owners only.** "Only owners" is no longer a separate choice. It is a private item with an empty list. - **Owners always pass.** No item closes to everyone. - **A nested folder or doc takes the strictest rule on its path.** A doc in a private folder is private. Its reader must pass every list on the path, which is the ADR 0020 and ADR 0023 rule. - **The person who makes an item private joins its list**, whatever their role. Nobody removes their own access (ADR 0023). A member may make an item private, with a list that keeps them in it. `visibility: private` is a change to access, so an agent never makes one. - **The top level** holds no `access:` (ADR 0020). A top-level `visibility: private` keeps the meaning ADR 0009 gave it: owners only, for the whole org. A person or a connection reads both levels, so for them that file is a list at the top level that they fail. `inheritedReachFor` then withholds every address, so a non-owner sees no doc, no queued change and no audit row. ### 2. The two old words read as `open` `escorted` and `sealed` read as `open` everywhere: in a doc's header, in a folder's `_folder.md`, in `notes.visibility`, and in a Vectorize vector. `visibilityFromStored` in `packages/core/src/permissions/visibility.ts` is the mapping, and `storedSpellingsOf` gives every stored word for an `IN (…)` clause. Nobody rewrites a person's Markdown. The Markdown is the ground truth (`AGENTS.md` rule 1), and the parser maps the word. A person who saves Open on an item that says `visibility: sealed` keeps those bytes, because they already mean Open. Migration `0029_two_visibility_levels.sql` changes derived columns only: - The migration sets `notes.restricted` for a doc that says `visibility: private` with no `access:`. Before this ADR the level kept members out of such a doc. Now the list does. The Worker finds a doc's list through two partial indexes: `notes_restricted_idx` and `notes_private_idx`, which the migration adds (`restrictedDocs` in `apps/worker/src/services/permissions.ts`). The second one finds a doc that the previous build commits after the migration, which that build stores as `private` with no mark. The Worker keeps a row only when its header sets a list. - `notes.visibility` holding `escorted` or `sealed` becomes `open`. - `notes.visibility` of a doc with its own list becomes `private`. A doc that inherits a folder's list is re-stamped by the next policy commit in its project; no read depends on that column for a person or a connection. The migration 0003 `CHECK` constraints stay. They still admit the old words, and nothing writes one. ### 3. A connection reads what its person reads (amends ADR 0009 §3 and ADR 0020 §1) Five kinds of connection read the same docs: a token from Connect, a Quick Connect link, an OAuth connection, the in-page WebMCP agent and the in-app assistant. Each reads every doc its creator can open now. That is every open doc, and every private doc whose lists the creator passes. The read scope of a connection is its creator's person scope (`readScopeForLink` in `apps/worker/src/services/permissions.ts`). - `LinkReach`, `maxReachFor` and `reachAllowedFor` are gone. `ceilingForPerson()` and `ceilingForConnection()` both read both levels. `ceilingForPublic()` reads `open` only. - `session_tokens.reach` stays in D1 with its `CHECK`. A new row holds `open` (`STORED_CONNECTION_REACH`), and nothing reads the column. - `POST /api/tokens` accepts `reach` and ignores it, so an older client still works. The token list, the mint response, `/try/link`, `tools.json` and `GET /api/webmcp/tools` no longer send `reach`. - The creator-membership check and the creator's denials (ADR 0020, ADR 0023) are unchanged. A person who leaves takes every connection they made with them. - The capability (`read`, `suggest`, `write`) and the org's agent writes setting (ADR 0022) are unchanged. ### 4. The level does not change what a write does (amends ADR 0009 §4) ADR 0009 queued every agent change to a doc above `open`, and refused one to a `sealed` or `private` doc. Both rules were about a connection that could not read the doc. Now a connection reads what its person reads. So a private doc that its person can open takes the same capability chain as an open doc (`assistantNodeCapability`, `writeDispositionFor`). The chain is the instance policy, the org, the project, the folder, the doc, the connection's scope and the org's agent writes setting. A person's write is unchanged: a person who can read an item can edit it. A person or a connection whose person is not on a list gets `note-not-found`, as before. `capability_assistants: never` on a folder or a doc: no control in the web app sets it. A stored `never` keeps its stored behaviour. It refuses every agent change to the item, and no stored value widens away from it. It never changed what a connection reads, and it does not now. The level floor was also the only floor in `CommitService`'s hard clamp, the check inside `commit` that a door cannot skip. So the hard clamp now reads a stored `never` instead (`assistantNeverOn` in `apps/worker/src/services/permissions.ts`). It reads nothing else of the chain. A person who accepts a queued change re-runs `commit` with the agent actor, and a chain that says `suggest` must not refuse that accept. ### 5. What a person sees (amends ADR 0009 §7 and ADR 0023 §1) - **Share, the create doc form and the create folder form** offer two choices. Each has help text and a small figure. The choices are "Open — everyone in {org} can read it" and "Private — only the people and groups you add can read it. Owners can always read it." The people and group picker shows for Private. The web app no longer draws the "Connected agents can read it" checkbox. One sentence says: "Agents read what the person who connected them can read." - **Connect** states the fact and offers no choice: "Docs it can read: The docs you can read. It reads every Open doc, and the Private docs that are shared with you." The OAuth consent page says the same. - **Marks and chips** say Private: the tree lock, the doc list lock and the doc byline chip. - **The properties panel** shows Open or Private, and where it comes from. - `POST /api/share` takes `level: "open" | "private"` and `entries`. It refuses any other level with `share-level-unknown`. `GET /api/share` answers `own.level` as `open` or `private`, and no longer sends `agents` or `agents_locked`. ### 6. What does not change - **Public docs and publication** are a separate axis. `SIGNPOST_PUBLIC_DOCS`, `publicDocsScope` and `publication:` work as before, and an anonymous reader reads `open` only and passes no list. One consequence follows from section 2. The public site now serves a doc that says `escorted` or `sealed` inside a published folder, because both words mean `open`. To keep such a doc off the public site, make it private. - **Invariant I7.** A doc a session may not read appears in no response, for a person and for every connection. A private doc still occupies its address. An address picker (`ReadScope.reachesFolder`) and `CommitService` refuse a create or a move onto it as taken. They use the words a readable live doc there gets, and they send no sha. They use the same words when the private doc is deleted, because a readable deleted doc is revived and never refused. Only a folder the person may not open gets "no folder this account can open holds that address", as ADR 0020 decided. `apps/worker/test/invariants/read-scope.test.ts` holds the invariant. Its old `escorted` and `sealed` cases now assert that both words read as open. New cases cover a connection whose creator is on a private doc's list, and one whose creator is not. - **An agent never changes a level or a list** (ADR 0009, ADR 0023). ### 7. Release order The exposure starts when migration 0029 runs, not when the new Worker deploys. `pnpm release` is `db:migrate:remote`, then `vectorize:metadata`, then `wrangler deploy`. Between the first step and the last one, the previous build serves every request, and it reads the rows that 0029 changed: - Its public scope reads `notes.visibility IN ('open')`. After 0029, a row that said `escorted` says `open`, so that build serves the row at `/docs` and to a link that `/try` mints. - A connection of that build reads `open`, and a connection with `escorted` reach reads `escorted` too. After 0029, a row that said `sealed` says `open`, so every connection of that build reads it. If `wrangler deploy` fails, that build stays up with the wider reading until a deploy works. For almost every row that 0029 widens, the previous build now reads what the new Worker reads a moment later. Two rows are exceptions. `run-an-instance/operations` in the `docs` organization says `escorted`, in a published section. `signpost/security` in the `signpost` organization says `sealed`. The manifest now makes both `private`, and the previous build already reads `private` as owners only. So do the steps in this order: 1. Run the documentation ingest against the running Worker, once for each organization: `SIGNPOST_BASE=https://docs.kmem.app node scripts/ingest-signpost-docs.mjs --org docs --cookies `, then the same command with `https://signpost.kmem.app` and `--org signpost`. 2. Check that both docs now say `visibility: private`. 3. Run `pnpm release`. The previous build can also commit a doc during the window. It writes a doc that says `visibility: private` with `restricted = 0`, and it can stamp a row `escorted` or `sealed`. The new Worker reads the first through `notes_private_idx` (section 2). It reads the second as `open` on every surface: `visibilityFromStored` in `isWebPublished` and in the `/docs` listing, and `storedSpellingsOf` in `listPublicNotes` and in every read scope. ## Consequences - A member's agent now reads docs that were `sealed`, and every connection reads docs that were `escorted`. A person who wants a doc kept from some members, and so from their agents, makes it private. - A connection made by an owner reads every private doc in the org, because an owner passes every list. That was already true of the owner. - The documentation organization holds `docs/OPERATIONS.md` as `escorted`, inside a published section. After this change `/docs` would serve it. The manifest now gives it `private`, and `private` means owners only under the old Worker and the new one. **Run the documentation ingest for that organization before `pnpm release`**, so the stored header says `private` before migration 0029 runs (section 7). `docs/SECURITY.md` becomes `private` in the `signpost` organization for the same reason, with the same order. - There is one question in Share and no agents box, and the Connect form has no statement that a person has to translate. - `withheldFrom` and the briefing's withheld sentence stay. They are empty for every scope with a person behind it. A level no longer withholds anything from a person or a connection, and a private doc outside their lists is absent, not withheld. ## Options rejected - **Keep `sealed` as "members, no agents".** The owner removed it. It also made the Connect form describe a rule a person could not check without reading every doc's level. - **Rewrite `escorted` and `sealed` in the Markdown.** It would change bytes nobody asked to change, in every export and every git history. The parser maps the words instead. - **Keep `reach` and default it to the widest value.** A setting that always has one value is not a setting. The column stays for old rows, and nothing reads it. - **Make `access:` alone and `visibility: private` alone mean different things.** Two rules for one question is the defect `AGENTS.md` rule 5 names. Both set the item's list. ## What would make me revisit this - A request for a doc that members read and agents do not. That is a capability, not a level: `capability_assistants: never` would get a control, and it would need a read rule too. - A request to scope one connection to one folder. That narrows a connection below its person, and it would come back as a per-connection list, not as a level.