--- title: "Tenancy" type: reference tags: [signpost, docs, reference] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:26:12.640Z" --- # Tenancy One deployment serves every organization. This document says which host is which, what partitions one organization's bytes from another's, what one sign-in reaches, and which defect class the arrangement produces. [`ADR/0013`](./ADR/0013-multi-tenancy-supersedes-single-project.md) is the decision. An ADR is a dated record and is never revised, so this document is where the present tense lives. --- ## 1. Three kinds of host, and one of them is not a tenant One Worker answers on three shapes of hostname, and the shape decides what the request can do before any credential is read. | Host | Role | What it is | | --- | --- | --- | | `kmem.app` — the apex of `SIGNPOST_ROOT_DOMAIN` | `front-door` | Where a stranger signs up. It has no knowledge base | | `{org}.kmem.app` — one label above the root | `knowledge-base` | An organization. This is the product | | `sarani.app` and `{org}.sarani.app` — a retired root domain | none | A temporary `307` to the same path on `kmem.app`. The request never reaches a tenant. See §1.1 | | Anything else — another domain, a deeper label, or a label nobody has claimed | no tenant | `resolveTenant` answers `null` and the request falls back to the deployment's own default project | `subdomainOf(root, host)` is the one rule that separates them, and it returns `null` for three shapes deliberately: the apex itself, a host on another domain, and a host with **more than one** label above the root. kmem has no nested tenant, so treating the leftmost label of `a.b.kmem.app` as the organization would silently drop the rest. ### 1.1 The retired host kmem answered on `sarani.app` until 2026-09-11. `wrangler.jsonc` still routes `sarani.app/*` and `*.sarani.app/*`, and `SIGNPOST_LEGACY_ROOT_DOMAINS` names the domain. Every request to it answers a temporary redirect — `307` — to the same path and query on the matching `kmem.app` host: | From | To | | --- | --- | | `sarani.app/pricing?plan=team` | `https://kmem.app/pricing?plan=team` | | `acme.sarani.app/s/sp_...` | `https://acme.kmem.app/s/sp_...` | | `acme.sarani.app/mcp` | `https://acme.kmem.app/mcp` | Four facts decide the design, and each one is a rule somebody could get wrong. 1. **The middleware runs above every route group.** `app.ts` mounts `legacyHostRedirect()` on `*`, below the security headers and above every `tenantProjectContext()`. So no session is read, no tenant is resolved, and **no cookie is ever set on a legacy host**. A session cookie with `Domain=sarani.app` would be a live credential scoped to a domain this product is walking away from. 2. **An unclaimed organization still moves.** The redirect happens before the D1 lookup that would 404 it, so `acme.sarani.app` is answered by `acme.kmem.app` — the host that knows whether `acme` exists. Refusing first would strand every tenant. 3. **The status is `307`, and must never become `301` or `308`.** Two reasons, and both are load-bearing. It is **temporary** because `sarani.app` will point at a different project later: a permanent redirect is cached with no way to recall it, so that project's visitors would keep arriving at kmem. It is **method-preserving** because `/mcp` is a `POST`: a `301` or a `302` lets a client re-issue it as a `GET`, which would strip the JSON-RPC body. 4. **The redirect carries `Cache-Control: no-store`.** Where the request lands is computed from two variables an operator changes with a deploy. A stored redirect outlives both. The shape a legacy host must have is the shape every other host must have: the apex, or exactly one label above it. `a.b.sarani.app` was never served, so there is no address to move it to, and `kmem-sarani.app` is somebody else's name. `SIGNPOST_LEGACY_ROOT_DOMAINS` is a comma-separated list rather than one value, because a product moves domain more than once and each move that replaces the value drops the one before it. Naming the current root domain in the list is dropped rather than honoured: a deployment that redirected to itself would answer every request with a redirect to the host it arrived on. **The apex is fenced, not merely empty.** `routes/api/front-door.ts` refuses `/api` at the apex, because a session valid there is exactly what self-service sign-up produces, and without the fence a stranger could read and write the operator's own knowledge base through every `/api` route. The single-page app asks `GET /api/front-door` for its role, so it stops offering screens the fence would refuse. ## 2. Reserved subdomains, and the asymmetry that matters `RESERVED_SUBDOMAINS` in `@signpost/core/tenancy` is one list, imported by both applications rather than copied into each. A second copy is the defect class that gave this repository three disagreeing slug rules and, one wave later, two disagreeing clip rules. ``` kmem signpost www app api docs admin status demo mail static assets cdn blog help support login auth default ``` **Reservation is a registration rule, not a routing rule.** `createOrganization` refuses a reserved subdomain; `resolveTenant` does not consult the list at all. So a platform organization seeded on a reserved label keeps resolving, and no customer can ever take that label. That asymmetry is what "reserved for kmem itself" means in the refusal copy, and it is what lets kmem's own documentation sit on `docs` and its own knowledge base stay on `signpost` ([`ADR/0015`](./ADR/0015-two-documentation-organizations.md)). `signpost` is reserved for a second reason: it is the label the product answered on before the rename, and documents, links and chat histories still point at it ([`ADR/0014`](./ADR/0014-the-product-is-called-kmem.md)). **`default` is not reserved for branding.** It is reserved because `DEFAULT_PROJECT_ID` and `DEFAULT_PROJECT_SLUG` are both the literal string `default`, and `tenantScopedKey` treats that value as the deployment's own project — the one case where an R2 key is stored **unprefixed**. An organization claiming `default`, or claiming that literal string as its own id, would land its objects among the operator's own. ## 3. Four partitions, and what each one is enforced by A tenant boundary that is one forgotten `WHERE` clause deep is not a boundary. These are four different mechanisms, in four different systems, and the strongest ones are not application code. ### 3.1 D1 — a `project_id` column on every content row Migrations `0006_tenancy.sql`, `0007_row_tenancy.sql` and `0009_blocks_project_scoped_id.sql`. Every query composes `eq(table.projectId, ctx.projectId)`. The row that matters most is `session_tokens.project_id`, whose own schema comment states the model: *"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**."* A Quick Connect link is bounded by its organization first and by its rung second. **This is the partition with the weakest enforcement**, because a missing predicate is a query that still returns rows. Section 5 says what to do about that. ### 3.2 R2 — one physical key rule `tenantScopedKey(ctx, logicalKey)` in `storage/blobs.ts` is the only rule, and every function that touches the bucket calls it first: ``` orgs/{orgId}/projects/{projectSlug}/{logical key} a real organization {logical key} projectId === "default" ``` The unprefixed case is the deployment's own corpus, whose objects were written before prefixes existed. Prefixing them in code would make every existing object unreadable on the day the code deployed. ### 3.3 Vectorize — a namespace, which the service enforces `vectorNamespaceForProject(projectId)` gives a real organization `chunkVectorNamespace(projectId)`, and gives the default project `null`, for exactly the reason R2 has an unprefixed case: a namespaced query matches only vectors written *under* that namespace, so naming one for the older corpus would turn every dense query on it into an empty result — no error, fusion silently degrading to BM25. **A blank namespace does not narrow, it widens.** An `undefined` or empty-string namespace on a Vectorize query searches every namespace the index holds, which is every organization on the deployment in one response. So the call refuses a missing or blank namespace rather than defaulting to silence. That refusal is the mechanism; a metadata filter would have been a clause somebody could forget. ### 3.4 Durable Objects — one namespace per organization `org_routing.do_schema_version` records which shape a given organization's own Durable Object expects its storage to be in. Durable Object namespaces are scoped to the Worker **script name**, which is why ADR 0014 refuses to rename the Worker: a rename creates a new script, and `NoteDoc`, `IngestAgent`, `GitAgent` and `RATE_LIMITER` all lose their stored state. Every live editing session's document history is in a `NoteDoc`. ## 4. One person, one passkey, one session, every organization A person signs in once and is signed in on every organization they belong to. Two values do that and they are the same value: - **The session cookie's `Domain`** is `sessionCookieDomain(env, host)`, which returns the root domain when the host is the apex or one label above it, and `null` otherwise. - **The passkey's relying-party id** is `passkeyRelyingPartyId`, which is the same function with a fallback to the hostname. A browser will not even offer a passkey outside its relying party, so one passkey registered at `docs.kmem.app` works at every `{org}.kmem.app`. They are one function because a cookie that travels and a passkey that does not — or the reverse — is a sign-in that works on one organization and silently fails on the next. **Passkey sign-in is off at launch.** The web app hides every passkey surface behind the `passkeys` launch flag (`docs/LAUNCH_FLAGS.md`), because the ceremony fails for some people. The relying-party rule above applies when somebody turns the flag on. Passkeys then appear on the account page, `/account`, and not in any organization's Settings, because a passkey belongs to a person (ADR 0021). **A root domain a browser would reject is refused, with the cause and the fix.** `cookieDomainRefusal` names three shapes — not a domain, a single label, a public suffix — and when it fires the cookie is host-only rather than widened. Only the auth configuration reports it, which is a gap worth knowing about: a browser that ignores a `Set-Cookie` says nothing. **Being resolved is not being a member.** `resolveTenant` answers "which organization does this hostname belong to". It runs before authentication and cannot ask whether the signed-in person belongs to that organization. `requireSession()` in `routes/api/auth.ts` is where the `member` row is checked, once there is a person to ask about. Conflating the two produced an authentication bypass to the platform superadmin. **A role is a comma-separated list.** `packages/core/src/membership.ts` parses the list, and it is the one place that may. A call site that compares `member.role === "owner"` reads an owner stored as `"admin,owner"` as a plain member, and that person then loses every `private` note. ## 5. The defect class this build produces, and why a green suite does not see it **The lesson is the third one this codebase carries and it is the strongest.** A green suite is not evidence of correctness. A suite cannot see a defect living between how it configures the system and how a deployment does. And now: **a suite cannot see a defect its fixtures cannot represent.** With one organization in the fixtures, a query that reads every row and a query that reads this organization's rows return the same rows on every input, for ever. The missing predicates were not untested. They were untestable. Three instruments found them, and the order is worth keeping: a test with **two organizations** in it, opening the running product and looking at it, and several agents working on one tree in a day. **The deliverable was therefore the fixture, not the assertion.** `apps/worker/test/support/tenant-fixture.ts` holds one organization named "Meridian Pay" at `meridian-pay`, **whose display name and subdomain deliberately differ**, so that a caller that confuses the two fails loudly rather than passing by coincidence. If you are adding a read surface, add the second organization to your fixture before you add the assertion. ## 6. Verifying tenancy locally, and the trap that reads as a failure **Delete the `routes` block from the dev config, or you will measure one tenant and call it three.** `wrangler dev` sets each request's URL host from the first `routes` entry, not from the `Host` header. With this repository's `routes` in place every host reads as that one entry: the apex answers `role: "knowledge-base"`, the front-door fence never fires, and correct per-host behaviour looks broken. A verifier nearly filed a working fix as a defect. Copy the config, delete `routes`, and resolve the hostnames with `--host-resolver-rules`. Related and separate: **one `wrangler dev` serves one host**, so a check across "four hosts" on one server is silently one host. Run `wrangler dev --host` once per hostname. ## 7. Custom domains are wired and deliberately off `CUSTOM_DOMAIN_ROUTING_ENABLED` in `tenancy/resolve.ts` is `false`. There is no Cloudflare for SaaS on this account, so no certificate exists for a domain kmem does not own, and a request would fail TLS before a handler saw it. `org_routing.custom_domain` is written and read anyway, so turning the flag on costs one line and no migration. `RELAUNCH_NOTES.md` at the repository root says which parts of the tenancy work have run against production and which have not. ## 8. Related reading - [`ADR/0013`](./ADR/0013-multi-tenancy-supersedes-single-project.md) — the decision, and what ADR 0001 got right before it. - [`ADR/0009`](./ADR/0009-permission-model.md) — the visibility ladder, which is the boundary *inside* an organization. Tenancy is the boundary around one. - [`ADR/0015`](./ADR/0015-two-documentation-organizations.md) — the case where the tenant partition is used as a security boundary on purpose rather than only as isolation. - [`TAXONOMY.md`](./TAXONOMY.md) — the naming and capability model, including the grant table this build deliberately does not have yet. - [`SECURITY.md`](./SECURITY.md) — the threat model, and what is not yet defended.