--- title: "ADR 0015 — The documentation is two organizations, and the manifest is the router" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:40:05.195Z" --- # ADR 0015 — The documentation is two organizations, and the manifest is the router **Status** Accepted, amended by [ADR 0026](./0026-two-visibility-levels.md) (a doc is `open` or `private`, and a link has no reach; see bound 2 below) · **Date** 2026-08-26 · **Relates to** [ADR 0009](./0009-permission-model.md) (the visibility ladder), [ADR 0013](./0013-multi-tenancy-supersedes-single-project.md) (one deployment, many organizations), [ADR 0014](./0014-the-product-is-called-kmem.md) (the rename, and what is never renamed), [`docs/TAXONOMY.md`](../TAXONOMY.md) §9 (the `publication` axis), `docs/DOCS-SITE.md` (how to run it) ## Decision kmem's documentation lives in **two organizations on the one deployment**. | Organization | Subdomain | Holds | Read by | | --- | --- | --- | --- | | kmem documentation | `docs` | The 36 documents the manifest marks `audience: public` | Anybody. No credential | | kmem | `signpost` | All 57 ingested documents, the public set included | Members of the organization | **`docs/manifest.json`'s `audience` column is the router.** It already classified every document `public` or `internal`, it is already what the documentation site and the ingest read, and it is already checked against the directory by `pnpm docs:manifest`. Nothing new decides who sees what. One publisher reads that column twice, with two filters, and writes into two organizations. Two fields are added to the manifest and neither of them is a second answer to an existing question: - **`section`** — which of six folders a public document lands in. It is the only editorial fact no existing field carries. `kind` says what a document is; `audience` says who it is for; neither says what a reader meets first. - **`organizations`** — the two rows above, with each one's audience filter, its permalink layout and its `SIGNPOST_PUBLIC_DOCS` value. ## What already existed, and what each of the four keeps doing The hard question was never how to make two organizations. It was what "the public documentation as an organization" means when four mechanisms already answer part of it. None is replaced. **1. `docs/manifest.json` classifies 60 documents.** It stays the one place, and it gains the `section` field. The alternative — a `nav` file listing the corpus, which is what `mkdocs.yml` and `sidebars.js` are — is a second list of the same documents, and it is wrong the first day somebody adds a page and forgets it. `pnpm docs:manifest` fails on a document that has no row; it cannot fail on a document missing from a hand-kept sidebar, because a sidebar is allowed to be a subset. **2. `routes/docs.ts` answers one address in four representations.** It stays exactly as it is, including the `Sec-Fetch-Dest: document` discriminator that exists because handing ChatGPT's fetcher an empty single-page-app shell produced a confident fabrication on 2026-08-14 that cited the page it had not read. That failure is worse on a public documentation site than anywhere else in the product, so the route that already solved it is the route the site is built on. The route gains a derived navigation — `sections` on the index — and the derivation is the argument: `SIGNPOST_PUBLIC_DOCS` is already an **ordered** list of folders and a note's `folder` already says which one it is under, so the sidebar's order is the operator's variable, its membership is the note's own address, and its headings come from `sectionTitle`, which turns `how-it-works` into `How it works`. There is no title field beside the folder. A title beside the folder is the second list again, in miniature. **3. `routes/public/**` and the `publication` axis stay, and they are not the same mechanism.** `publication` is `private | link | public`, authored per note, in the note's own frontmatter, by its own author. `SIGNPOST_PUBLIC_DOCS` is a folder list, set per deployment, by the operator. They are two gates for two different people, and both are needed: | | Who decides | Shape | Surface | | --- | --- | --- | --- | | `SIGNPOST_PUBLIC_DOCS` | The operator | A folder, and everything under it | `/docs/{permalink}` | | `publication: public` | The note's author | One note | `/public/{permalink}` | A customer publishing one page from a private knowledge base needs the second and must not be given the first. kmem publishing a whole documentation site needs the first and would otherwise be authoring `publication: public` into 36 files by hand. Collapsing them would take one of those two away. **`/sitemap.xml` was the place they had to compose, and it did not.** It listed only the `publication: public` corpus. On the organization whose entire purpose is a documentation site nobody authors `publication:` at all, so every page answered `200` and the sitemap was empty: a search engine was told the site had nothing on it. It now names both corpora, one URL per note, with the `/docs` address winning when a note satisfies both gates — because that is the address the site's own navigation links to, and telling a crawler about two identical pages with no canonical between them is how a documentation site competes with itself. **4. The `AGENTS.md` managed region is the pattern, not a thing to duplicate.** It is already the product writing a file it does not own, between two markers, raising a proposal rather than clobbering a hand edit. The documentation organizations are the same idea at corpus scale: the Markdown in the repository is the ground truth, the notes are a projection of it, and the projection is re-derivable from one file. Nothing about the managed region changes, and no second projection mechanism is written. ## Why two organizations rather than two folders This is the part that is worth the migration, and it is not filing. **A session link is confined by `project_id`, and `project_id` is the organization.** `session_tokens.project_id` is described in `packages/db/src/schema.ts` as "the row-level tenancy key: which project this link may ever address at all, once threaded — the boundary `reach` narrows *inside*, not the one this column draws *around*." Every read through a link is a query already filtered by it. So the two arrangements differ in what stops a reader-minted link reaching `SECURITY.md`: - **One organization, two folders.** The visibility ladder stops it. `SECURITY.md` is `sealed`, `DEFECTS.md` and `OPERATIONS.md` are `escorted`, and a link at `open` reach cannot see them. This is correct, it is tested, and it is **one authored word per document away from being wrong.** A manifest row that says `visibility: open` where it meant `sealed` publishes the threat model. - **Two organizations.** The tenant partition stops it, and the ladder stops it as well. There is no row for `SECURITY.md` in the documentation organization at all, so no value of any field on any note can make a link minted there return it. The second is the arrangement where the failure mode is not reachable rather than not taken. That is the same reason `ReadScope` is a compile error rather than a convention, and the same reason invariant I1 is enforced by a type. **The ladder stays and is still checked**; this adds an outer fence, it does not replace the inner one. The cost is duplication: the 36 public documents exist as notes in both organizations. That is acceptable and it is not two implementations of anything. The file in git is the ground truth, both sets of notes are projections of the same manifest by the same script, and `pnpm docs:manifest` fails when the manifest and the directory disagree. Rule 5 forbids a second *implementation* of a concept, not a second *output* of one. ## What happens to the two live organizations **`signpost`, 41 notes, keeps its subdomain forever.** It becomes the private organization and takes `--audience all`, because the people building kmem read the public documents too. Three reasons not to move it: 1. Its notes are addressed under `signpost/`. ADR 0014's line is "never rename a name a machine resolves as an address", and a permalink is one. The ingest is a `POST`-then-`PATCH`, so a renamed root creates 41 second notes rather than moving 41 first ones. 2. `signpost` is in `RESERVED_SUBDOMAINS` and stays there forever, so no customer can ever claim the address the product answered on before the rename. Reservation is refused at `createOrganization`, not at `resolveTenant`, so the platform's own organization on a reserved label keeps resolving. That asymmetry is what "reserved for kmem itself" means, and it is what lets the documentation organization sit on `docs`. 3. Its display name is a word a person reads, so it becomes `kmem`. Renaming that is free and ADR 0014 requires it. **`meridian-pay`, 96 notes, is untouched.** It is the demo corpus and the live twin of `apps/worker/test/support/tenant-fixture.ts`, and `docs/RETRIEVAL.md`'s measured numbers are taken against a corpus one changed byte makes incomparable. It publishes six folders of its own through the same `SIGNPOST_PUBLIC_DOCS` mechanism and gains the derived navigation and the sitemap entries for free, which is the check that this design is a mechanism and not a special case for kmem's own documents. **`docs` is new and empty until somebody runs the publisher.** Nothing in this change writes to a live deployment. ## What a reader-minted Quick Connect link reaches A reader of the public documentation may mint a link for themselves through `/try`, which already exists, is already argued, and is currently off. No second minting path is built. `/try` is inert on every instance where `SIGNPOST_PUBLIC_MINT` is unset, and it is set to `read` on the documentation organization and on nothing else. On the shared deployment that takes two variables, because the first is one value for every organization: `SIGNPOST_PUBLIC_MINT=read`, and the documentation organization's id in `SIGNPOST_PUBLIC_MINT_ORG_IDS` (`publicMintModeFor` in `routes/try/mint.ts`). Such a link reaches **exactly the public documentation, and nothing else.** Five bounds, and each one is a mechanism that already existed: 1. **The organization.** `session_tokens.project_id` is the documentation organization's project. No note in `signpost` or in `meridian-pay` is addressable through it, whatever its visibility. 2. **The rung.** `PUBLIC_LINK_REACH` is the constant `"open"` and `LinkReach` cannot hold `sealed`. A `CHECK` in migration 0003 restates it where TypeScript cannot reach. *Amended by [ADR 0026](./0026-two-visibility-levels.md):* a link has no reach now. A reader-minted link reads what its principal reads, and that principal is a visitor with no address, so no access list names it: it reads `open` docs and no `private` doc. `docs/OPERATIONS.md`, which this bound kept away as `escorted`, is `private` in the manifest for the same reason. 3. **The verb.** `SIGNPOST_PUBLIC_MINT` is `read` there, so the link has no write scope at all, and `assertPublicMintIsSafe` refuses the combination that would matter at boot rather than documenting it as a footgun. 4. **The clock.** `PUBLIC_LINK_EXPIRES_IN` is one hour, the shortest lifetime `services/tokens.ts` accepts. The URL is the credential and a reader will paste it into a transcript they do not control, so the bound that helps is the one on time. 5. **The bill.** Three mints per address per hour, 200 across the instance per day, 60 requests a minute and 1,000 an hour per link through the `RateLimiter` Durable Object, and four concurrent expensive operations per link. Invariant I6 is unchanged and is what makes revocation real: a revoked or expired link is rejected on every endpoint, and `test/invariants/token-lifecycle.test.ts` is the proof. **Nothing about the reader is stored.** The per-visitor cap is keyed on an HMAC of the address under this instance's own secret, truncated to 16 hex characters, and `routes/try/mint.ts` explains why an unkeyed hash of an IP address is the IP address. **The link is not more privileged than the site.** Every note it can read is already served to anybody at `/docs` with no credential at all. The link is faster and better — search, a budgeted context pack, block addresses, `remember` refused rather than silently dropped — and it is not wider. That sentence is the test for whether a public mint is safe on an instance, and it is why the same variable must stay unset on `signpost`. ## Alternatives rejected **A static documentation site built from `docs/*.md`.** MkDocs, Docusaurus, VitePress. It is the obvious answer and it is the product declining to eat its own cooking: kmem's claim is that documentation belongs in one indexed, addressable, provenance-carrying corpus rather than a pile of Markdown somebody has to remember to rebuild. `routes/docs.ts`'s own header made this argument before this ADR existed. A build step also reintroduces drift the moment somebody edits a page in the app, and editing a page in the app is the product. What is taken from MkDocs is its shape, not its machinery: sections, an ordered sidebar, one page per address, a sitemap, and Markdown a reader can fetch. All six are now true and none of them needed a generator. **One organization, with `public/` and `internal/` folders.** Cheaper, and it makes the visibility ladder the only thing between a reader-minted credential and the threat model. See the argument above. The ladder is good and it is one authored word deep. **Publishing the public set with `publication: public` instead of a folder list.** It would use one mechanism instead of two, and it would mean authoring `publication: public` into 36 documents whose ground truth is a file in a git repository — 36 places to state a fact the manifest already states once. It also takes `/docs` away from every operator who wants a whole folder published and does not want to touch 200 notes. **Renaming the `signpost` organization to `kmem`.** A subdomain is an address; ADR 0014 priced this class of change file by file and the answer does not change because the thing being renamed is an organization rather than a bucket. The display name is a word and it is renamed. The address is not. **Putting the section in the document's own front matter.** `docs/CONVENTIONS.md` §3 already refused per-file marking, and its first reason settles this one too: a per-file marking cannot be checked for completeness. A document with no section would silently default; a manifest row with no section fails the build naming the file. ## Consequences - `pnpm docs:manifest` gains four checks: every published row has a declared `section`; no two published rows resolve to the same address on the site; the sections themselves are unique, legal path segments with a stated reason; and the `section`-layout organization's `public_docs` string names exactly the declared sections in exactly the declared order. That last one is the check that keeps `SIGNPOST_PUBLIC_DOCS` and the navigation from drifting. - `scripts/ingest-signpost-docs.mjs` takes `--org` and reads the row out of the manifest. It refuses when the host it was pointed at does not name that organization, which is the mistake that would publish the internal set to the open internet — and it is a mistake nobody would notice until a stranger did. - Adding a document now costs **three** things, not two: a row in `docs/manifest.json`, a row in `docs/README.md`, and — if it is public — a `section`. All three fail the build. - `docs/ADR/**` and `docs/studies/**` keep the name they were written with, per ADR 0014. Both organizations serve them unedited. This ADR says "kmem" because it was written after the rename; ADR 0001 says "Signpost" because it was written before, and neither is corrected. - Nothing in this change was run against a live deployment. The two organizations described here are a design and a publisher; `docs/DOCS-SITE.md` §6 is the runbook and says what is unrun. --- The relaunch removed `docs/DOCS-SITE.md`. Its content is now in `docs/DEPLOYMENT.md`.