--- title: "ADR 0009 — One level on the note, one reach on the link" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:38:40.865Z" --- # ADR 0009 — One level on the note, one reach on the link **Status** Accepted, amended by [ADR 0026](./0026-two-visibility-levels.md) (two levels, `open` and `private`; a connection reads what its person reads; §1, §3, §4 and §7) — status updated 2026-08-18; the model shipped in full: `VISIBILITY_LEVELS` in `packages/core/src/permissions/visibility.ts`, the read-scope invariant (`apps/worker/test/invariants/read-scope.test.ts`), the agent-write clamp, and the landing page describe it as built · **Date** 2026-08-14 · **Supersedes** nothing · **Relates to** PRD §5.2 (Quick Connect token rules — **this ADR needs an amendment there**), §5.3 (the briefing), §3.7 (the single write path), phase 7 (review queue), §11 S5/S6, invariants I1 and I6, ADR 0002 (bearer, not OAuth), ADR 0008 (briefing cache), `docs/studies/2026-08-14-permission-model.md`, `docs/studies/2026-08-14-briefing-design.md` ## Context The `rush-skills/link-prototype` demo carries a permission model the operator judged "quite mature and closer to what I actually want, but a little too complicated with 3 permission settings per page/folder". Three nullable dials on every node: ``` audience company | invited who among PEOPLE may read it ai open | escorted | sealed whether a MACHINE may read it change direct | reviewed | locked what a write does ``` `docs/2026-08-14-permission-model.md` measured that model rather than judging it, and four numbers decide this ADR. **The per-note burden is already near zero, and the complaint is real anyway.** Over the demo's 30 notes: 23 explicit dial values in 90 slots (25.6%), 21 of them at the seven space roots where a `CHECK` constraint requires all three, 2 on pages. **21 of 23 pages set nothing (91.3%.)** There is no interface for setting a dial — no route, no form, no `UPDATE` of `audience`, `ai` or `change_policy` anywhere in `store.ts`. Nobody has ever been asked three questions. What exists is `policyChips`, which prints all three resolved values on every row of every screen: **93 chips on `/wiki` to convey 21 authored decisions**, 72 on `/share`, 9 on one note page. The operator is reacting to a renderer. **One dial is inert.** `change` has three values and an agent session sees two: through a link, `reviewed` and `locked` produce the same outcome and `editor` and `owner` are the same role. It discriminates fully in exactly one cell — an editor, in a browser. `locked` prevents nothing `reviewed` permits, because a queued proposal still needs an owner. It is on one note in thirty. **One dial is a role grant with the sign flipped.** `effectiveRole` ends `return resolvePolicy(nodeId).audience === "company" ? "reader" : null` (`store.ts:466`). `audience: company` **is** a reader grant to an implicit everyone group, kept out of the grant table only because grants are additive with no deny row. 0 of 23 pages set it; all seven uses are space roots. **One dial is genuinely orthogonal, and it is the good one.** `ai` is checked in a branch that only runs once a person's read has already succeeded, so it is derivable from neither of the others. It buys the sentence no competitor says: *a note your colleague may read and your assistant may not.* Honest caveat, measured: `sealed` nodes = 6, of which `audience: company` = **0**. The flagship state is instantiated zero times in the demo's own corpus. Three facts about the main product change what can be built here at all. - **There are no per-note grants and no groups.** `user.role` is `owner | member` (`apps/worker/src/auth/schema.ts:199`) and the only role check in the worker is `requireOwner` (`auth/routes.ts:614`), which gates invite management. Every signed-in member reads everything. `audience` has nothing to be implemented against. - **`change` already exists, one level up.** `ProjectContext.writePolicy` is `open | review | locked` from `SIGNPOST_WRITE_POLICY` (`context.ts:155`), instance-wide, defaulting to `review`. - **The tree is two deep, measured.** All 100 notes of the dogfood corpus have permalinks of exactly two segments across six folders — `architecture` 21, `decisions` 21, `runbooks` 18, `conventions` 16, `state` 16, `people` 8 (`node /tmp/measure-visibility.mjs` over `eval/corpus/corpus.json`). Inheritance here is instance → folder → note. The demo's prefix walk and its depth cap of 3 are answering a question this repository's permalink format has already answered. And one fact about retrieval sets the engineering budget. Vectorize allows **10 metadata indexes**, seven are used, **a metadata index only covers vectors upserted after it was created**, and a filter clause naming an unindexed property returns nothing at all, silently — measured, and the trap `retrieval/vectorize-index.ts` is built around. Any per-note read filter that must be applied inside the vector query costs one of the three remaining slots and one full reindex. ## What is being decided Not "three dials or fewer". The question is **which facts belong to the note, which belong to the principal, and which belong to the credential** — because two of the demo's three dials are facts about principals wearing a note's clothes. - `audience` is about principals: which people. It belongs in a grant table. - `change` is about principals and instance policy: whose writes go live. It belongs to the role and to `SIGNPOST_WRITE_POLICY`, both of which exist. - `ai` is about the note: *how far may this content travel?* A pricing sheet is sensitive whoever is asking. That is the only one of the three that is a property of the content. ## Decision **A note carries exactly one setting. A link carries exactly one more. Everything else is derived.** ### 1. `visibility` on the note — four values, totally ordered ``` open Anyone signed in. Any live credential: MCP, any share link, recall. escorted Anyone signed in. A machine only through a link a person aimed at it. sealed Anyone signed in. No machine. Ever. No link, no escort, no MCP, no recall. private Owners only. No machine. Ever. open < escorted < sealed < private ``` `escorted` and `sealed` keep the demo's words with the demo's exact meanings, so migration is a rename rather than a re-think. `private` is the top of the ladder because it is the only rung that narrows *people*, and today it has exactly one meaning: `user.role = 'owner'`. When per-folder grants arrive it becomes "owners and the people named on this folder" and no other rung changes. **Stored in the note's own YAML frontmatter as `visibility:`, absent meaning inherit.** Not in D1 alone: rule 1 says the R2 bucket plus a git clone contains everything, and a permission that lives only in the index means a rebuild from R2 silently republishes every sealed note. D1 carries a `notes.visibility` column as the queryable copy of what the Markdown says, written by `CommitService` like everything else. ### 2. Folder default — one file, one value `notes/{folder}/_folder.md`, frontmatter only, written through `CommitService` and excluded from indexing. It holds the folder's `visibility` and nothing else for now. A folder with no such file inherits the instance default, which is `open`. The file exists rather than a table because of rule 1 again, and because a folder that is moved or renamed takes its policy with it. Two of the demo's seven spaces would need one; four of the main product's six folders would not. ### 3. `reach` on the link — and the guarantee is a type, not a branch ```ts type Visibility = "open" | "escorted" | "sealed" | "private"; type LinkReach = Extract; // and nothing else, ever ``` A link reads a note iff `rank(resolved) <= rank(link.reach)`. Because `LinkReach` cannot hold `sealed` or `private`, **no link at any reach can carry a sealed note, and the compiler is what says so.** The demo enforces the same claim with a branch in `resolveNode`; this repository's precedent for a guarantee that must not be defeated is `WriteToken`, which is a type. Invariant I1 has been defeated three times by reasonable-looking code; the corresponding read rule gets the stronger mechanism from the start. `reach` defaults to `open` at mint. Minting above `open` requires a label — the label is the reason, and it is what the audit row and the review queue print. A link's reach may never exceed what its `created_by` could read. `scope` (`read` | `write`) is unchanged and orthogonal: it says which verbs exist. A read-only link does not have `write` in the briefing, does not have it in `tools.json`, and gets a 403 at the door. That is the demo's `mode` and the study calls it the cleanest thing in the model; it is adopted verbatim. ### 4. The write clamp — derived from the level, enforced in `CommitService` ``` resolved = max(instance default, folder value, note value) agent session (any bearer credential — Quick Connect link or MCP token): resolved > open -> queue a proposal. Always. No reach, no scope, no role changes this. resolved = open -> SIGNPOST_WRITE_POLICY decides: open applies, review queues, locked refuses. resolved >= sealed -> not reachable: unreadable, therefore unaddressable. signed-in person in the browser: owner -> applies member -> applies to anything they can read; `private` is owners only. ``` The sentence survives word for word: **no text a machine produced becomes the live version of a note above `open` without a named person accepting it.** It is stronger here than in the demo in two ways. It sits inside `CommitService`, which is the only thing in the program that can write and already requires a `WriteToken`, rather than in a `writeOutcomeFor` that a second caller could bypass by convention. And `sealed`/`private` notes are unaddressable by an agent, so the clamp is never the last line of defence for the notes that matter most. **An agent may not change `visibility`.** A commit whose author kind is `agent` and which alters the resolved visibility of a note is refused by `validateChange` with the reason. An agent-created note inherits its folder; an explicit `visibility` in an agent payload is a refusal, not a default. **Until the phase 7 queue exists, an agent write above `open` is refused with its reason, never committed.** `routes/quick-connect/writes.ts:110` currently answers `write_policy: "review"` and commits anyway, with a note in the response saying so. That is defensible for an instance-wide policy an operator chose; it is not defensible for a per-note level a person set. The clamp ships with the queue or it ships as a refusal. ### 5. One resolver, and every read surface must take it The single most transferable defect in the demo is P1: `/review` and `/activity` enforce none of the three dials, because `listProposals()` filters on `status` alone and `decide()` never calls `effectiveRole`. Measured: a contractor with no grant on `labs` gets a 404 on the note and is shown its full proposed body, its attribution, and working Accept and Reject buttons. **The resolver was correct; there are three read surfaces and one of them called it.** So: a `ReadScope` value, produced once per request from the credential, is a required argument of every function that returns note content — `recall`, `read`, `browse`, the notes API, the proposal queue, the audit view, the briefing. Enforced by **invariant I7** and a static scan modelled on `test/invariants/write-path-allowlist.ts`, with the same rule that every exception costs a written justification. ### 6. In retrieval, it filters the candidate set, never the results `visibility` becomes one clause of `RetrievalFilter` (`packages/core/src/retrieval/types.ts:117`, pure), one property on `Candidate`, one clause in the Vectorize metadata filter, one clause in the existing lexical `WHERE`, and one clause in the pure `applyRetrievalFilter` that re-applies everything over materialised candidates. Post-filtering is not an option and the reason is written in `retrieval/dense.ts`: a vector that comes back and is discarded still occupied one of the `denseLimit` slots, so the dense list arrives at fusion short and an answer at rank 48 is lost to four candidates that had no business being fetched. It degrades as a ranking problem, not as a permission problem. When a query's best answers were withheld, `recall` says so — `withheld: [{folder, count}]` — rather than returning a thinner pack. A model handed a short pack with no explanation invents; that is finding F3 in `docs/2026-08-14-chat-clients.md`, where Gemini refused to follow a link, did not say so, and made an answer up. ### 7. What a person actually sets **Common case: nothing.** A new note inherits its folder; a new folder inherits `open`. Expected authored values on the main product's corpus: **one** (`people/` → `sealed` or `private`). **Exception case: one control, on the folder.** Four options in the words a person would use, not the enum: ``` Who may read notes in people/ ? ( ) Everyone here, and any assistant open ( ) Everyone here. An assistant only through a link I aim at it escorted (o) Everyone here. No assistant, ever sealed ( ) Only owners. No assistant, ever private ``` **On a note: one control, and it is a sentence.** "Same as `people/` — everyone here, no assistant" with a single "Make this tighter" affordance. Choosing it unfolds the same four options with everything at or below the folder's level disabled, because a child may only tighten. **And the display rule that answers the operator's actual complaint: a badge appears only where a node differs from what it inherits.** On the demo's content that is 2 of 23 pages; after migration to this model, 1 of 30 notes. A list view carries no per-row badge at all — it carries one line above the list: "Everything in `people/` is sealed to assistants." Target: at most one badge per screen region, zero when nothing differs, against the demo's 93. ### 8. How the briefing renders it Measured with js-tiktoken 1.0.21, `cl100k_base`, the encoder `packages/core/src/chunk/tokens.ts` uses (`node /tmp/measure-visibility.mjs`): | Shape | Tokens | Per unit | | --- | --- | --- | | **This model: the ladder once, exceptions by folder** | **96** | fixed | | Per-folder table, if every folder had to be named | 75 | 12.5 / folder | | Demo-shape per-note capability table, this corpus | 2,615 | 26.1 / note | Twenty-seven times cheaper, and it scales with folders (6) rather than notes (100). That is not a rendering trick: **it is affordable because there is one inherited value, so there is nothing per-note to print.** Three dials force a per-note table, because any note may differ on any axis. The block is prose, and the rules for it are: ``` ## What this link may read This link reads notes marked `open` — 92 of the 100 notes here. Eight notes under `people/` are sealed to assistants and are not in this link; ask Ankur if you need one. Nothing else is withheld. Anything you write to a note above `open` is queued for a person to accept, and the reply says which happened. Nothing you write goes live on a sealed note. ``` - **Closed notes are named by folder and count, never one row per note.** `docs/2026-08-14-briefing-design.md` measured the demo's per-note form at 56 tokens a row, which is roughly 200,000 tokens on a 4,000-note instance. The honesty is kept; the disclosure is one folder name instead of eight note names, and the list is bounded by folder count. - **The link never discloses more than its minter can see.** The briefing names, by folder and count, every note `created_by` can read and the link cannot. That is the demo's "your access can never exceed Ankur's", made checkable. - `tools.json` carries `reach` and the resolved ladder for a client that follows links; the briefing never depends on that, because three of four measured clients refuse to follow one. ## Options considered ### A. Keep the three dials; fix only the interface The study's own headline, and it is half right — the interface is the dominant cost, and this ADR adopts its display fix wholesale. Rejected as the whole answer for three reasons. `audience` cannot be implemented in the main product: there is no grant table, no groups, and two roles. Shipping it would mean shipping a column whose only reachable value is the one everybody already has. `change` is inert through the door the product is judged on — two distinct outcomes for any agent session, one for an owner — and the main product already answers it at the instance level. And the briefing cost is structural: three independent dials mean a per-note capability table, measured at 2,615 tokens on this corpus against 96, against a 4,000-token target. ### B. One ordered level per note, all three dials derived from it Tempting, and rejected on the one case that matters. A single ladder over people and machines together cannot express **maximally open to people, closed to machines** — the sentence the operator named as the thing not to lose — because the ladder ties the two axes. Any rung strict enough to exclude every machine also excludes colleagues. What survives from it is the ordering, which this ADR keeps: the four rungs *are* a total order, and the reason the sentence is still sayable is that the ladder is ordered on **machine reach**, with people affected only at the top rung. That is the one asymmetry, and it is stated rather than smuggled. ### C. Move the machine-read decision entirely to the link Explicitly weighed, because it is cheaper than anything else here: zero per-note settings, zero migration, no Vectorize index, no reindex. Rejected on the guarantee. If the note says nothing, "no assistant may ever read the pricing sheet" becomes "no assistant reads it through links minted correctly" — a procedural control whose failure is silent, unbounded and one mis-clicked mint away from the whole corpus. The demo's strongest claim is that a sealed note cannot leak through *any* link, and it is strongest precisely because it does not depend on whoever mints being careful. Partially adopted, which is the synthesis: the link carries `reach`, and `reach` ranges over the two lowest rungs only, in the type. The cheap part is taken; the guarantee is not traded for it. ### D. Policy by label — mark a note `#confidential` and let one rule govern many Rejected, and this is the firmest rejection. **Tags are body text.** `blocks.tags` is "a JSON array of `#tag` tokens" parsed from block content, so a tag is a word anyone may type in a paragraph. An agent holding `write` on an `open` note could change that note's audience by editing prose — a permission system whose input is the thing the permission system is protecting. **Measured: 78 distinct tags over 100 notes, every note tagged.** That is a vocabulary nobody curates, being asked to carry policy. It also cannot be enforced where it must be. Vectorize filters scalar metadata; a set-valued tag property is not a filterable clause, so a label rule would have to be applied after the query, which is the recall-shortening failure in option 6 above. Labels stay useful for *suggesting* a level in the interface — "three notes tagged `#pci` are `open`; review?" — and never for deciding one. ### E. Per-note ACLs now: grants, groups, subjects Rejected as premature, not as wrong. It needs an accounts model the product does not have, and the demo's own corpus uses per-note human restriction on **0 of 23 pages**. Build it when there is a second human who needs a different answer from the first. The seam is left open: `private` is the rung that becomes "owners and the people named on this folder", and nothing else moves. ### F. Adopt the demo's model unchanged Rejected on its own measurements: 3 of 18 combinations unreachable through an undocumented clamp that silently rewrites what a person typed, 6 of 15 reachable combinations used, two of those on one leaf note each, and the flagship combination used zero times. ## Consequences ### What it costs to migrate the demo's content | Demo state | Notes | Becomes | Exact? | | --- | --- | --- | --- | | `company/open/direct` | 9 | `open` | yes, with `SIGNPOST_WRITE_POLICY=open` | | `company/open/reviewed` | 9 | `open` | yes, with the instance default `review` | | `invited/open/reviewed` | 4 | `open` | **no** — loses the people restriction | | `invited/open/locked` | 1 | `open` | **no** — loses the people restriction | | `invited/escorted/reviewed` | 1 | `escorted` | yes | | `invited/sealed/locked` | 6 | `private` | yes | Authored values afterwards: **two folder files** (`finance`, `people` → `private`) and **one note override** (`labs/atlas/evaluation` → `escorted`). Three, against the demo's 23. Five notes lose their people-restriction, all of them in the fabricated `labs`/`project atlas` subtree. ### What the main product must build | # | Work | Where | | --- | --- | --- | | 1 | `Visibility`, `LinkReach`, `resolveVisibility(instance, folder, note)`, `RetrievalFilter.maxVisibility`, `Candidate.visibility` | `packages/core` — pure, zero Cloudflare imports | | 2 | `notes.visibility`, `session_tokens.reach`, migration `0003` | `packages/db` | | 3 | `visibility:` frontmatter key; an unrecognised value is a **commit refusal**, never a silent default | `packages/core/src/markdown`, `services/commit.ts` `validateChange` | | 4 | `notes/{folder}/_folder.md`, written only through `CommitService`, excluded from indexing | `services/commit.ts`, ingest | | 5 | `ReadScope` + **invariant I7** + static scan | `apps/worker/test/invariants/read-scope.test.ts` | | 6 | Vectorize metadata index `visibility` (7 → 8 of 10), **then** a full reindex | `retrieval/vectorize-index.ts`, `wrangler vectorize create-metadata-index` | | 7 | One clause in the lexical `WHERE`; one clause in `applyRetrievalFilter` | `retrieval/lexical.ts`, `packages/core` | | 8 | `reach` at mint, in the guard, in `tools.json`, in the briefing | `routes/api/session-link.ts`, `quick-connect/guard.ts`, `services/briefing.ts` | | 9 | The clamp; refusal-with-reason until the phase 7 queue lands | `services/commit.ts`, `quick-connect/writes.ts:110` | | 10 | The interface: one folder control, one note sentence, badge only on difference | `apps/web` | **Order matters on (6) and it is the one irreversible step.** A metadata index does not cover vectors upserted before it existed, and a filter on a property with no index behind it matches nothing silently — which looks like a ranking regression, not a broken filter. Create the index, then reindex the whole corpus. Measured price of that reindex on the current 459 chunks: **91.9 neurons, about $0.0010** (`docs/DECISIONS-PENDING.md:1336`). Two of ten metadata index slots remain afterwards. ### The PRD amendment this needs PRD §5.2 says: *"Exactly two scopes: read and write. Nothing more granular in v1 — granularity is a cloud-tier feature."* `reach` is granularity on a token and this ADR cannot be accepted without amending that line. The proposed wording keeps the rule it was protecting: > **Exactly two scopes: `read` and `write`.** `write` implies read. A link additionally carries a > `reach` of `open` or `escorted`, which is not a scope: it selects which notes exist for the link, > not which verbs it has. `reach` can never carry a `sealed` or `private` note — see ADR 0009. Subtree scoping (a `folders` list on the link) is **deliberately not proposed here.** It is the natural next thing, it would cut the briefing further, and it is independent of this decision: adding it later does not reopen this ADR. ### What is lost against three dials — the honest list 1. **Per-note human restriction.** The demo's `invited` — "this note is for named people" — is not expressible below `private`, which means owners. Five notes of the demo's thirty use it. Not a regression for the main product, where every signed-in member already reads everything, but it is a capability the demo has and this does not. 2. **A machine seeing more than a person.** The ladder is monotone: machine reach is never wider than human reach. The demo's `labs` inverts it — restricted to people, open to machines — and the study calls that the case proving the two axes differ. Measured: it is the only inversion instantiated in the demo's corpus (5 notes); the inversion in the other direction, `company` + `sealed`, is instantiated **zero** times. 3. **Per-folder write policy.** "This folder is a scratchpad, let the agent write freely" is not sayable; `SIGNPOST_WRITE_POLICY` is instance-wide. The demo's corpus splits 9 notes `direct` against 9 `reviewed` at equal read-sensitivity, so this is a real case. It is the safe direction to be missing — the default is the careful one — and the fifth rung that would fix it (`workshop`, below `open`) is specified in the revisit trigger below rather than built now. 4. **`locked` distinct from `reviewed`.** Gone, on the study's evidence that it prevents nothing `reviewed` permits. 5. **The escort as a first-class object.** A 60-minute door with a reason in the ledger becomes a short-expiry link with a mandatory label. Weaker: the reason is a label, not a required field on a distinct kind. Mitigated by requiring the label on any mint above `open`. 6. **Per-note honesty about closed notes.** A sealed note is named by folder and count, not individually. Deliberate: the per-note form costs ~200,000 tokens on a 4,000-note instance. Of the demo's 15 reachable combinations this model expresses 4 states. Of the 6 combinations its corpus actually uses, 4 map exactly and 2 lose the people-restriction. ### What it buys that the demo does not have - A briefing that states the whole policy in **96 tokens** and does not grow with the corpus. - `sealed` guaranteed by a type rather than a branch. - The clamp inside `CommitService`, the only thing that can write. - The demo's P1 (three read surfaces, one resolver call) prevented by construction through I7. - The demo's P3 (accepting a stale proposal silently reverts the note) does not transfer: `proposals.payload` is a `Change`, and acceptance goes through `commitProposalDecision` → `commit`, which raises `CommitConflictError` rather than overwriting. - Zero settings per note in the common case, and — unlike the demo — a screen that can actually set one. ## What would make me revisit this - **A second human principal needing a different answer per note.** The first real `invited` case brings back grants; `private` becomes "owners and the people named on this folder" and this ADR is extended, not replaced. - **Measured demand for a scratch folder.** If more than one folder wants agent writes to apply directly under an instance policy of `review`, add `workshop` as a fifth rung *below* `open` (looser on writes, identical on reads, so the total order and strictest-wins survive). It is safe only where the resolved level is `open`. - **More than 20% of notes overriding their folder.** That would mean the folder is the wrong unit of policy, and the right response is a second level of nesting — or labels as a suggestion mechanism feeding a person's decision, never as the decision. - **Folder count past about 50.** The briefing's exception list is bounded by folders; at 50 it is ~625 tokens and needs the same degradation rule the note table needed. - **A `sealed` note that nobody ever creates.** The demo's flagship state has zero instances in its own corpus. If the main product's dogfood instance carries no `sealed` note six months from now, the machine axis is a claim rather than a feature, and the honest move is to say so and collapse the ladder — not to keep a rung alive because it demos well. - **Any measurement showing the visibility clause costs recall.** The whole design assumes it filters the candidate set for free inside Vectorize. If `precision@5` moves on the eval after the reindex, the filter is wrong, not the gate. --- The relaunch deleted `docs/studies/2026-08-14-permission-model.md` and `docs/studies/2026-08-14-briefing-design.md`. No document holds their content now.