--- title: "kmem REST API" type: reference tags: [signpost, docs, reference] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:26:42.040Z" --- # kmem REST API `/api/*` is the surface the web app and the CLI drive (PRD 5.4). It is not the door an assistant uses: agents connect over MCP at `/mcp` or through a session link at `/s/{token}`, which are documented in `docs/MCP.md` and use a different credential. **This file is generated from the Zod schemas the handlers validate against** (`apps/worker/src/routes/api/`). Every field, default, bound and refusal below is read out of the same object the running code parses with, and a test compares this file to the rendered one on every build. Do not edit it by hand: change the schema and regenerate. ## Authentication A session cookie issued by `better-auth`. Sign in through the web app, or run `kmem login` for the CLI. A kmem token (`sp_...`) is **not** accepted here, and that is deliberate: a token is routinely pasted into a chat with a model, and this API can create connections, revoke them and delete notes. One is a capability handed to an assistant; the other is an administrative session. `/api/auth/*` is `better-auth`'s own sign-in surface and is the only path under `/api` that does not require a session — it is how a session comes to exist. Every other path answers `401` without one: ```json { "error": { "code": "unauthorized", "message": "This request carried no signed-in session. Sign in to the web app, or run `kmem login` for the CLI. A kmem token (sp_...) is not accepted here; it is the credential for /mcp and /s, which cannot create connections or delete notes.", "fix": "Sign in to the web app, or run `kmem login` for the CLI. A kmem token (sp_...) is not accepted here; it is the credential for /mcp and /s, which cannot create connections or delete notes.", "retryable": false, "request_id": "req_01JB8Z3K5Q7V" } } ``` ## The error contract PRD 5.3 states it in the words the Quick Connect briefing puts in front of every model that connects: > Errors: 4xx protocol · 200 + {error} application · 503 retry once. It is a contract about *client behaviour*. There are four kinds of failure, and the status a kind gets depends on who is listening: | Kind | Meaning | `/api` (this file) | `/mcp` and `/s` | | --- | --- | --- | --- | | `protocol` | The request itself was wrong. Do not retry it as is. | 4xx | 4xx | | `application` | The request was fine; the answer is a refusal. | 4xx | **200** with `{error}` | | `retryable` | Nothing is wrong with the request. Try again. | 503 + `Retry-After` | 503 + `Retry-After` | | `internal` | A bug. | 500 | 500 | `/api` renders application errors as ordinary HTTP statuses — `404` for a note that is not there, `409` for a conflict — because its callers are a React app and a CLI, where a 200 carrying a failure means every caller has to remember to check a field, and the one who forgets shows an empty note instead of "this note was deleted". The agent doors return 200 for the same body, because an LLM client that receives a 4xx treats the call as fatal and stops calling the tool. The body is identical in both dialects: | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `error` | object | yes | — | | | `error.code` | string | yes | — | Stable, kebab-case, safe to switch on. | | `error.message` | string | yes | — | Cause first, then fix. | | `error.fix` | string | yes | — | What to do about it. | | `error.retryable` | boolean | yes | — | | | `error.request_id` | string | yes | — | Also on the `X-Request-Id` header. | | `error.retry_after_seconds` | integer | no | — | | Every string in it is passed through the same redactor the audit log uses, so a credential pasted into a note cannot come back out in an error message (S2). `retryable` is true on exactly the two failures that carry `retry_after_seconds`: a 503 for something transient, and a 429 for the rate limit. A 429 answered `false` until 2026-09-02, beside a `Retry-After` header and a `fix` saying to send the same request again. ### Two codes the session-link door answers A request to `/s/…` that names no credential, and one that names an endpoint the door does not serve, both answered `404 no-such-route` until 2026-09-02 — and that refusal listed three doors the caller could not use. | Code | Status | When | | --- | --- | --- | | `no-credential` | 400 | `/s/` with nothing after it. Nothing was read. | | `unknown-endpoint` | 404 | `/s/{link}/{name}` where `{name}` is not one of the nine verbs. The refusal echoes `{name}`, capped and redacted, and never the credential segment. | ### A refusal that did not come from kmem Every refusal kmem writes is the JSON object above. **A 4xx whose body is not that object came from the network in front of this Worker.** It says nothing about your credential and nothing about your request. Measured on 2026-09-01 against `kmem.app` and a tenant host, which are the same Worker on two hostnames: | `User-Agent` | `GET /health` | | --- | --- | | `curl/8.5.0` | `200`, the JSON body | | `python-requests/2.31.0` | `200`, the JSON body | | `Mozilla/5.0 …` | `200`, the JSON body | | `Python-urllib/3.11` | `403`, `error code: 1010`, 17 bytes | | `libwww-perl/6.67` | `403`, `error code: 1010`, 17 bytes | The user agent alone decides it. The path does not matter, and the credential does not matter. The refusal carries none of this Worker's response headers — no `X-Request-Id`, no `Content-Security-Policy` — because the Worker never runs. There is therefore no log line to find. The cause is Cloudflare's Browser Integrity Check. It is on by default on a new zone, and it refuses a short list of library user agents. Nothing in this repository can turn it off, and no code change here is the fix. **The fix is a zone setting, and only the operator can apply it.** Open Security → Settings for the zone and turn off **Browser integrity check**. To keep it on elsewhere, add a WAF custom rule with the *Skip* action for the hostnames kmem serves. An agent lost 19 consecutive `POST /remember` calls to this refusal in August 2026, and read the bare `403` as "my token lacks permission". Read the body rather than the status. A `403` from kmem always names a `code` and a `fix`. ## Provenance and freshness **Every response that carries a claim or a block carries its provenance and its freshness.** That is not a nicety: the interface draws a *status chip* beside each one (design/DESIGN.md, provenance and status) — an icon and a word saying who wrote it, whether a human has verified it, and how much its age has discounted it, with the bar's opacity on a continuous scale from 1.0 to 0.35 at the half-life. An API that omitted those facts would make the product's signature element impossible to draw. | Field | Encodes | | --- | --- | | `author_kind` | Trail green for a human, slate for an agent. | | `state` | Filled chevron when verified, hollow when not, a cross when superseded. | | `freshness` | The opacity of the bar. `exp(-age_days / half_life)`, PRD 4.2. | | `past_half_life` | The amber hollow chevron. Arithmetic, not an assertion. | | `description` | The same facts in words, for the accessible name. The glyph is never the sole carrier of meaning. | Half-lives are per note type: `convention` 365 days, `decision` and `reference` 180, `note` 90, `state` 14 — because "auth is 70% done" rots in a fortnight and a convention does not. A **note** carries counts rather than a waymark. A note is not a claim, and using the waymark decoratively destroys its meaning. ## Conventions - **Timestamps** are ISO-8601 UTC strings, everywhere, in every direction. A model reading `"2026-03-14T09:22:00Z"` can reason about how old a decision is; it cannot do that with epoch milliseconds. - **Addresses** are `kmem://folder/slug` for a note and `kmem://folder/slug#blk_xxxxxxxx` for a block. Parse them with the grammar, never by splitting on `#`. - **Permalinks in a path** keep their slashes: `GET /api/notes/backend/auth-decision`. - **Commands take their argument in the body** (`POST /api/notes/restore`) so that no route depends on registration order to be reachable. - **Paging** is `limit` and `offset`. `page.has_more` is true when a full page came back; it is not a total count. - **Compare-and-swap**: send `expected_sha`, the `content_sha` you read. Without it a write is last-writer-wins, which is right for `git pull` and wrong for a person. - **Every response is `Cache-Control: no-store`.** - **Every mutation** goes through `CommitService.commit(ctx, change)` — the content-addressed blob, the blocks, the provenance rows, the full-text sync, the audit row and the index job (PRD 3.7, invariant I1). There is no path in this API that writes any other way. ## Endpoints | Method | Path | What it does | | --- | --- | --- | | `GET` | [`/api/health`](#get-api-health) | The same report `/health` serves, behind a session. | | `GET` | [`/api/tree`](#get-api-tree) | The folder structure. Structure only, never content. | | `GET` | [`/api/search`](#get-api-search) | A lexical listing for the tree and the command palette. | | `POST` | [`/api/recall`](#post-api-recall) | The ContextPack (PRD 4.3). | | `POST` | [`/api/orgs`](#post-api-orgs) | Create an organization; the caller becomes its owner. | | `GET` | [`/api/orgs/availability`](#get-api-orgs-availability) | Whether a subdomain is free, as a 200. | | `PATCH` | [`/api/orgs/{orgId}`](#patch-api-orgs-orgid) | Rename an organization. An owner or an admin only. | | `GET` | [`/api/orgs/{orgId}/verification`](#get-api-orgs-orgid-verification) | Whether this org uses verification. | | `PATCH` | [`/api/orgs/{orgId}/verification`](#patch-api-orgs-orgid-verification) | Turn verification on or off for an org. An owner or an admin only. | | `GET` | [`/api/orgs/{orgId}/agent-writes`](#get-api-orgs-orgid-agent-writes) | Whether a connection in this org may change docs without a person in Review. | | `PATCH` | [`/api/orgs/{orgId}/agent-writes`](#patch-api-orgs-orgid-agent-writes) | Turn agent writes on or off for an org. An owner or an admin only. | | `DELETE` | [`/api/orgs/{orgId}`](#delete-api-orgs-orgid) | Delete an organization. The owner only, and in five ordered steps. | | `GET` | [`/api/orgs/{orgId}/deletion`](#get-api-orgs-orgid-deletion) | Whether a deletion finished, without asking for it again. | | `GET` | [`/api/org/{orgId}/projects`](#get-api-org-orgid-projects) | Every project in an organization. | | `POST` | [`/api/org/{orgId}/projects`](#post-api-org-orgid-projects) | Create another project inside an organization. | | `POST` | [`/api/ask`](#post-api-ask) | A person asks their docs a question, answered in prose. | | `GET` | [`/api/ask/starters`](#get-api-ask-starters) | Three to five starter questions for an empty Ask screen. | | `POST` | [`/api/assistant/ask`](#post-api-assistant-ask) | One question, one answer, cited. | | `POST` | [`/api/assistant/write`](#post-api-assistant-write) | One proposed change, through the same clamp as any assistant. | | `GET` | [`/api/billing/entitlement`](#get-api-billing-entitlement) | This organization's plan, its limits, and its usage so far. | | `GET` | [`/api/notes`](#get-api-notes) | List a folder. | | `POST` | [`/api/notes`](#post-api-notes) | Create a note. | | `POST` | [`/api/notes/restore`](#post-api-notes-restore) | Bring a soft-deleted note back. | | `POST` | [`/api/notes/move`](#post-api-notes-move) | Change a note's address. A rename and a move are the same operation. | | `GET` | [`/api/notes/{permalink}`](#get-api-notes-permalink) | One note: its Markdown, and every block with its waymark. | | `PATCH` | [`/api/notes/{permalink}`](#patch-api-notes-permalink) | Replace a note's contents. | | `DELETE` | [`/api/notes/{permalink}`](#delete-api-notes-permalink) | Soft-delete a note, with a reason. | | `POST` | [`/api/folders/move`](#post-api-folders-move) | Rename or move a folder, and every doc under it. | | `POST` | [`/api/folders/delete`](#post-api-folders-delete) | Delete every doc in a folder, as one batch the Undo restores. | | `POST` | [`/api/folders/restore`](#post-api-folders-restore) | Undo one folder delete, by its batch id. | | `GET` | [`/api/share`](#get-api-share) | Who can open one doc or one folder. | | `POST` | [`/api/share`](#post-api-share) | Change who can open one doc or one folder. | | `GET` | [`/api/groups`](#get-api-groups) | The org's people and its groups. | | `POST` | [`/api/groups`](#post-api-groups) | Create a group. Its address is fixed from its name. | | `PATCH` | [`/api/groups/{id}`](#patch-api-groups-id) | Rename a group. Its address stays. | | `DELETE` | [`/api/groups/{id}`](#delete-api-groups-id) | Delete a group and its memberships. | | `POST` | [`/api/groups/{id}/members`](#post-api-groups-id-members) | Add org members to a group. | | `DELETE` | [`/api/groups/{id}/members/{userId}`](#delete-api-groups-id-members-userid) | Take one person out of a group. | | `GET` | [`/api/blocks/{block_id}`](#get-api-blocks-block-id) | One block and its waymark. | | `POST` | [`/api/blocks/edit`](#post-api-blocks-edit) | `append` · `prepend` · `replace_block` · `set_property`. | | `POST` | [`/api/media`](#post-api-media) | Store one image against a note and describe it. | | `GET` | [`/api/media/{asset_id}/{filename}`](#get-api-media-asset-id-filename) | The bytes of one image. | | `POST` | [`/api/media/{asset_id}/describe`](#post-api-media-asset-id-describe) | Ask the vision model again for an image already stored. | | `GET` | [`/api/backlinks/{permalink}`](#get-api-backlinks-permalink) | Every edge pointing at a note. | | `GET` | [`/api/history/{permalink}`](#get-api-history-permalink) | Every version, and a diff between any two. | | `POST` | [`/api/history/restore`](#post-api-history-restore) | Make an old version current again. | | `GET` | [`/api/proposals`](#get-api-proposals) | The review queue. | | `GET` | [`/api/proposals/counts`](#get-api-proposals-counts) | How many proposals are in each state. | | `POST` | [`/api/proposals/{id}/accept`](#post-api-proposals-id-accept) | Commit the proposed change, then stamp the row. | | `POST` | [`/api/proposals/{id}/reject`](#post-api-proposals-id-reject) | Decline a proposal. | | `POST` | [`/api/proposals/batches/{batchId}/accept`](#post-api-proposals-batches-batchid-accept) | Decide one whole submission, item by item. | | `GET` | [`/api/tokens`](#get-api-tokens) | Connections. | | `POST` | [`/api/tokens`](#post-api-tokens) | Create a connection. The one response that carries a credential. | | `POST` | [`/api/tokens/document`](#post-api-tokens-document) | A connection's document, for the signed-in person looking at it. Records no use. | | `DELETE` | [`/api/tokens/{id}`](#delete-api-tokens-id) | Revoke a connection. | | `PATCH` | [`/api/tokens/{id}`](#patch-api-tokens-id) | Rename a connection. | | `GET` | [`/api/audit`](#get-api-audit) | Who did what, and a witness over the record. | | `GET` | [`/api/sessions`](#get-api-sessions) | Every sitting an assistant wrote in, newest first. | | `GET` | [`/api/sessions/{id}`](#get-api-sessions-id) | One sitting, one row per write. | | `GET` | [`/api/sessions/{id}/undo`](#get-api-sessions-id-undo) | What undoing this sitting would do. Writes nothing. | | `POST` | [`/api/sessions/{id}/undo`](#post-api-sessions-id-undo) | Reverse a whole sitting, in one act. | | `GET` | [`/api/org/{orgId}/defaults`](#get-api-org-orgid-defaults) | What assistants may do across this organization, and what actually governs. | | `PATCH` | [`/api/org/{orgId}/defaults`](#patch-api-org-orgid-defaults) | What people and assistants may do across a whole organization. | | `GET` | [`/api/trust`](#get-api-trust) | How much of this knowledge base a person has checked, and whose it is. | | `GET` | [`/api/usage/hot-stale`](#get-api-usage-hot-stale) | Notes recall keeps serving that nobody has checked lately. | | `GET` | [`/api/usage/never-recalled`](#get-api-usage-never-recalled) | Notes nothing has ever asked for, oldest first. | | `GET` | [`/api/usage/verify-tasks`](#get-api-usage-verify-tasks) | The verify queue: what to check, and what was being quoted from it. | | `GET` | [`/api/usage/import-batches`](#get-api-usage-import-batches) | What could be cleared in one bulk verify, largest first. | | `POST` | [`/api/usage/verify`](#post-api-usage-verify) | Mark checked: one note, a folder, or one import batch. | | `POST` | [`/api/verify`](#post-api-verify) | Move a claim, a section or a whole note between the four states. | | `POST` | [`/api/trust/duplicates`](#post-api-trust-duplicates) | Is this already written down? Cosine above 0.92. | | `POST` | [`/api/export`](#post-api-export) | Download a knowledge pack: one Markdown file, or a zip of notes. | | `POST` | [`/api/ingest`](#post-api-ingest) | Open a folder-ingest run and get back what to upload. | | `POST` | [`/api/ingest/{runId}/files`](#post-api-ingest-runid-files) | Upload one batch of content for an open ingest run. | | `GET` | [`/api/ingest/{runId}`](#get-api-ingest-runid) | The report of an ingest run, so far. | | `GET` | [`/api/ingest`](#get-api-ingest) | The limits a client sizes its manifest and batches against. | | `POST` | [`/api/import`](#post-api-import) | Upload a Markdown-and-zip archive or an Obsidian vault, and import it as a job. | | `POST` | [`/api/import/memory/preview`](#post-api-import-memory-preview) | Propose folders and docs for another AI's memory export. Writes nothing. | | `POST` | [`/api/import/memory`](#post-api-import-memory) | Import the memory docs a person reviewed, as an import job. | | `GET` | [`/api/import/jobs/{id}`](#get-api-import-jobs-id) | One import job: where it is, and what happened to each file. | | `GET` | [`/api/import/jobs`](#get-api-import-jobs) | This organization's most recent imports. | | `GET` | [`/api/import`](#get-api-import) | The formats and limits a client should size its upload against. | | `POST` | [`/api/import/conflicts/{id}/supersede`](#post-api-import-conflicts-id-supersede) | Resolve a queued import conflict by superseding what it disagrees with. | | `GET` | [`/api/git/status`](#get-api-git-status) | Whether a repository is connected, and what the last sync did. | | `POST` | [`/api/git/sync`](#post-api-git-sync) | Run a sync now. | | `POST` | [`/api/reindex`](#post-api-reindex) | Put pending chunks back on the index queue. | | `GET` | [`/api/webmcp/tools`](#get-api-webmcp-tools) | The seven tools, for a page to declare to a browser agent. | | `POST` | [`/api/webmcp/call`](#post-api-webmcp-call) | Run one tool as the browser agent, on the signed-in person's authority. | | `GET` | [`/api/worklog`](#get-api-worklog) | An agent session's own account of its work. | | `POST` | [`/api/worklog`](#post-api-worklog) | Append one worklog entry. Append-only: no update, no delete. | | `POST` | [`/api/admin/session`](#post-api-admin-session) | Start this browser's admin session, with a reason. | | `GET` | [`/api/admin`](#get-api-admin) | Platform spend, tier and health overview. | | `GET` | [`/api/admin/orgs`](#get-api-admin-orgs) | The organization directory. | | `GET` | [`/api/admin/orgs/{org}`](#get-api-admin-orgs-org) | One organization's spend, tier and queue detail. | | `DELETE` | [`/api/admin/orgs/{org}`](#delete-api-admin-orgs-org) | Remove an organization whose owner cannot sign in. | | `GET` | [`/api/admin/orgs/{org}/deletion`](#get-api-admin-orgs-org-deletion) | What an operator's deletion has left to do. | | `GET` | [`/api/admin/worklog`](#get-api-admin-worklog) | The platform audit view, across every organization. | | `GET` | [`/api/admin/costs`](#get-api-admin-costs) | What each organization costs to run, per day or per month. | ### `GET /api/health` The same report `/health` serves, behind a session. Live counts from D1: how much of the corpus is embedded, how far behind the index is, which schema version this database has, and what the MCP tool surface costs. `200` for `ok` and `degraded`, `503` for `unavailable` — a degraded index is a true statement about a working service; an unmeasurable one is an outage. **Success:** `200` **Response** The same object `GET /health` returns. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `status` | `"ok"` \| `"degraded"` \| `"unavailable"` | yes | — | One word, so a status bar can colour it without parsing prose. | | `project` | object | yes | — | Null only on a `GET /health` answer that could not resolve the project; `/api/health` always has one. | | `project.id` | string \| null | yes | — | | | `project.name` | string \| null | yes | — | | | `version` | object | yes | — | | | `version.build` | string | yes | — | | | `version.schema` | object \| null | yes | — | Read from the database's own migration ledger, not from a constant. | | `index` | object | yes | — | | | `index.embedding_coverage` | number \| null | yes | — | Embedded over total, in [0, 1]. | | `index.lag_seconds` | number \| null | yes | — | | | `index.chunks_total` | integer \| null | yes | — | | | `index.chunks_embedded` | integer \| null | yes | — | | | `index.chunks_pending` | integer \| null | yes | — | Committed, counted, and not searchable. The number that is never hidden (I4). | | `index.embedder` | string \| null | yes | — | The model building the index, or null. | | `mcp` | object | yes | — | | | `mcp.tool_definition_tokens` | integer \| null | yes | — | | | `mcp.tool_count` | integer \| null | yes | — | | | `mcp.budget_tokens` | integer | yes | — | NF8: 3,500. | | `mcp.within_budget` | boolean \| null | yes | — | | | `findings` | string[] | yes | — | Everything worth saying in words, in the order it should be read. | | `measured_in_ms` | number | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/health ``` ```json { "status": "degraded", "project": { "id": "default", "name": "kmem" }, "version": { "build": "0.0.0", "schema": { "applied": 1, "latest": "0000_initial" } }, "index": { "embedding_coverage": 0, "lag_seconds": 42, "chunks_total": 8, "chunks_embedded": 0, "chunks_pending": 8, "embedder": null }, "mcp": { "tool_definition_tokens": null, "tool_count": null, "budget_tokens": 3500, "within_budget": null }, "findings": [ "8 of 8 chunks are committed but not embedded, so retrieval cannot see them yet. Coverage is 0% and the oldest unindexed commit is 42 seconds old.", "No embedding pipeline is installed in this build, so any backlog above will not drain on its own. The search index has not been built for this instance yet." ], "measured_in_ms": 6 } ``` --- ### `GET /api/tree` The folder structure. Structure only, never content. Three bounded queries rather than a scan of every note, so it stays cheap at the 50,000 notes NF4 allows. A folder that was not expanded because `depth` ran out says so with `truncated`; ask again with `path` set to it. No note body appears anywhere in this response, at any depth. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `path` | string | no | `""` | Folder to start from. `` is the top level. | | `depth` | integer | no | `1` | How many levels of folders to expand. `0` returns this folder's own contents. | | `limit` | integer | no | `200` | Most note stubs returned for the folder at `path`. | | `offset` | integer | no | `0` | Skip this many note stubs. `page.has_more` says whether another page exists. | | `order` | `"permalink"` \| `"updated"` \| `"title"` | no | `"permalink"` | How the note stubs for the folder at `path` are ordered, over the whole folder and then paged. `permalink`, the default, is address order. `updated` puts the most recently written first, and `title` sorts by title ignoring case; each breaks a tie on the address. A client that sorts one page itself ranks only that page: in a folder of 500, the newest of the first 200 addresses is not the folder's newest. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `path` | string | yes | — | | | `depth` | integer | yes | — | | | `root` | any | yes | — | | | `notes` | object[] | yes | — | Note stubs for the folder at `path` itself, in the order `order` names. | | `notes[].permalink` | string | yes | — | | | `notes[].ref` | string | yes | — | `kmem://permalink`. | | `notes[].title` | string | yes | — | | | `notes[].type` | string | yes | — | | | `notes[].updated_at` | string | yes | — | ISO-8601 UTC. | | `notes[].freshness` | number | yes | — | The note's decay over its type's half-life (PRD 4.2). | | `notes[].visibility` | `"open"` \| `"private"` | yes | — | The note's resolved level. Compare it with its folder's to decide whether to badge it: ADR 0009 §7 says a badge appears only where a node differs from what it inherits. | | `notes[].restricted` | boolean | yes | — | True when this doc's own frontmatter makes it private: `access:`, or `visibility: private` alone (ADR 0023, ADR 0026). A doc inside a private folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `notes[].provenance` | object | yes | — | Who wrote this doc and whether anybody has checked it. Here rather than one request per row: a browse row draws an author line and a status chip, and a tree that omitted them made every row say "Author not recorded" over provenance the instance had stored all along. Structure only still holds — this is metadata about the doc, never a word of its text. | | `notes[].provenance.author_kind` | `"human"` \| `"agent"` | yes | — | Who composed the current version: a person, or an assistant. | | `notes[].provenance.written_by` | string \| null | yes | — | The person who wrote the words, by name. For an upload this is the Markdown's own `author` front matter and never the importer, so a zip of somebody else's documentation is not attributed to whoever uploaded it. `null` when nothing recorded one, which a client renders as "Author not recorded". | | `notes[].provenance.uploaded_by` | string \| null | yes | — | The person who ran the import, by name. `null` for a doc that was not imported. | | `notes[].provenance.accepted_by` | string \| null | yes | — | The person who let an assistant's words in through review, by name. Always `null` on a human-authored doc, which has no proposal to accept. | | `notes[].provenance.verified_by` | string \| null | yes | — | The person who checked the current version, by name. | | `notes[].provenance.verified_at` | string \| null | yes | — | ISO-8601 UTC of the check `verified_by` names, or `null`. Beside the name rather than inside it, because the doc screen prints "Verified by {name} · {date}" and a browse row prints the name alone. | | `notes[].provenance.status` | `"superseded"` \| `"stale"` \| `"unverified"` \| `"accepted"` \| `"verified"` \| null | yes | — | The chip, or `null` for none. `null` is the answer for a doc a person wrote or uploaded that nobody has verified, staled or superseded: `unverified` and `accepted` belong to the assistant proposal path, and a person who writes a doc has proposed nothing. Never a value outside verified, accepted, unverified, stale, superseded. | | `notes[].provenance.mixed_authorship` | boolean | yes | — | True when the doc's claims carry more than one author kind or more than one author. `author_kind` and `written_by` describe the write the doc came from, which is the whole answer for most docs and a brief one for a doc a person wrote and an assistant later added a paragraph to. A client with room says so; a client without one reads `written_by` and is not wrong. | | `notes[].provenance.pending_proposals` | integer | yes | — | Open proposals that target this doc, counted over this reader's own read scope. `0` when nothing is waiting. | | `notes[].provenance.agent_name` | string \| null | yes | — | The agent that wrote the doc, by the name its connection carries. `null` for a doc a person wrote. | | `notes[].provenance.agent_owner` | string \| null | yes | — | The person who owns that agent's connection, by name, so a client can print "Codex for Ankur". `null` when no account matches the recorded owner, and for a reader who is not a signed-in member. Never an account id. | | `page` | object | yes | — | The page the `notes` array is. A folder can hold more notes than one response carries, and a truncation that reports success is the defect this field exists to prevent: when `has_more` is true, ask again with `offset` advanced by `returned`. | | `page.limit` | integer | yes | — | | | `page.offset` | integer | yes | — | | | `page.returned` | integer | yes | — | Rows in this response. | | `page.has_more` | boolean | yes | — | True when a full page came back, so there may be another. Not a total count. | | `totals` | object | yes | — | | | `totals.notes` | integer | yes | — | Live notes in the whole instance. | | `totals.folders` | integer | yes | — | Distinct folders in the whole instance. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/tree?path=&depth=1 ``` ```json { "path": "", "depth": 1, "root": { "path": "", "name": "", "note_count": 1, "total_note_count": 3, "visibility": "open", "restricted": false, "folders": [ { "path": "backend", "name": "backend", "note_count": 2, "total_note_count": 2, "visibility": "open", "restricted": false, "folders": [], "truncated": false } ], "truncated": false }, "notes": [ { "permalink": "readme", "ref": "kmem://readme", "title": "Readme", "type": "note", "updated_at": "2026-03-20T11:04:00.000Z", "freshness": 0.98, "visibility": "open", "restricted": false, "provenance": { "author_kind": "human", "written_by": "ankur", "uploaded_by": null, "accepted_by": null, "verified_by": null, "verified_at": null, "status": null, "mixed_authorship": false, "pending_proposals": 0, "agent_name": null, "agent_owner": null } } ], "page": { "limit": 200, "offset": 0, "returned": 1, "has_more": false }, "totals": { "notes": 3, "folders": 2 } } ``` --- ### `GET /api/search` A lexical listing for the tree and the command palette. Two lists, because a palette and a search screen want different things: `notes` matches titles and addresses with `LIKE`, and `matches` is BM25 over `chunks_fts`. Not a substitute for `recall` — it ranks by term frequency, not by relevance, freshness and verification, and it says so in `index`. The query is rewritten into quoted phrases before it reaches FTS5, so an apostrophe or a stray `AND` is searched for rather than interpreted. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `q` | string | yes | — | What to look for. Ordinary words: FTS5 operators are not interpreted. | | `limit` | integer | no | `20` | Most rows in each of `notes` and `matches`. | | `prefix` | `"true"` \| `"false"` | no | `"false"` | `true` treats the last word as a prefix, which is what a palette wants while somebody is still typing. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `query` | string | yes | — | The query as asked, echoed back. | | `match_expression` | string | yes | — | What was actually sent to FTS5 after the rewrite. Published so results are explicable. | | `notes` | object[] | yes | — | Notes whose title or address contains the query. What a command palette lists. | | `notes[].permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `notes[].ref` | string | yes | — | `kmem://permalink`. | | `notes[].title` | string | yes | — | | | `notes[].folder` | string | yes | — | `` for the top level. | | `notes[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `notes[].content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `notes[].created_at` | string | yes | — | ISO-8601 UTC. | | `notes[].updated_at` | string | yes | — | ISO-8601 UTC. | | `notes[].deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `notes[].freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `notes[].half_life_days` | number | yes | — | | | `notes[].visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `notes[].restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `matches` | object[] | yes | — | Full-text hits over chunk text, best first. | | `matches[].chunk_id` | string | yes | — | | | `matches[].permalink` | string | yes | — | | | `matches[].ref` | string | yes | — | `kmem://permalink`. | | `matches[].title` | string | yes | — | | | `matches[].note_type` | string | yes | — | | | `matches[].text` | string | yes | — | The chunk's text — what was actually indexed and matched. | | `matches[].rank` | number | yes | — | FTS5's BM25 rank. Lower is better; it is a distance, not a similarity. | | `matches[].blocks` | object[] | yes | — | Every block this chunk covers, each with its own waymark. A chunk is a run of blocks and they need not share an author. | | `matches[].blocks[].id` | string | yes | — | A block id, `blk_` followed by eight hex characters. | | `matches[].blocks[].ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx`. | | `matches[].blocks[].waymark` | object | yes | — | Provenance and freshness for one block: who wrote it, through which door, whether anybody has verified it, and how much its age has discounted it. | | `matches[].blocks[].waymark.author_kind` | `"human"` \| `"agent"` | yes | — | | | `matches[].blocks[].waymark.author` | string \| null | yes | — | The account id, or an agent identity, exactly as `provenance` recorded it. Null for a person's write served to a reader who is not a signed-in member, such as a published note. | | `matches[].blocks[].waymark.author_name` | string \| null | yes | — | The person `author` names, by `personDisplayName`. Null for an agent's write, when nobody was recorded, when no account on this instance matches, and for a reader who is not a signed-in member. Show `author` only when it is not an account id. | | `matches[].blocks[].waymark.agent_client` | string \| null | yes | — | `claude-code`, `cursor`. Null for human writes. | | `matches[].blocks[].waymark.agent_model` | string \| null | yes | — | | | `matches[].blocks[].waymark.on_behalf_of_name` | string \| null | yes | — | The person who owns the agent's connection, by name, so a client can print "claude-code for Priya". Null for a human write, when no account matches the recorded owner, and for a reader who is not a signed-in member. Never an account id. | | `matches[].blocks[].waymark.session_id` | string \| null | yes | — | Groups every write from one sitting. Null for a reader who is not a signed-in member. | | `matches[].blocks[].waymark.source` | `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` | yes | — | | | `matches[].blocks[].waymark.created_at` | string | yes | — | ISO-8601 UTC. | | `matches[].blocks[].waymark.state` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `matches[].blocks[].waymark.verified_by` | string \| null | yes | — | The account id of whoever last set `state`. A verifier only while `state` is `verified`: on a stale or superseded claim it names whoever marked it so, which may be an agent's label. Null for a reader who is not a signed-in member. | | `matches[].blocks[].waymark.verified_by_name` | string \| null | yes | — | The person `verified_by` names. Null on the same three counts as `author_name`. | | `matches[].blocks[].waymark.verified_at` | string \| null | yes | — | ISO-8601 UTC. | | `matches[].blocks[].waymark.accepted_by` | string \| null | yes | — | The account id of the person who accepted these words through a review decision, or null. The third trust rung: a person let the words in and did not attest to each line — a different act from `verified_by`, recorded beside it. Null for a reader who is not a signed-in member. | | `matches[].blocks[].waymark.accepted_by_name` | string \| null | yes | — | The person `accepted_by` names. Null on the same three counts as `author_name`. | | `matches[].blocks[].waymark.accepted_at` | string \| null | yes | — | ISO-8601 UTC of the acceptance. Both acceptance fields are set, or neither. | | `matches[].blocks[].waymark.freshness` | number | yes | — | `exp(-age_days / half_life)`, in (0, 1]. The opacity of the waymark's bar. | | `matches[].blocks[].waymark.half_life_days` | number | yes | — | The half-life this note type decays over (PRD 4.2). | | `matches[].blocks[].waymark.past_half_life` | boolean | yes | — | True once the claim is older than its half-life. | | `matches[].blocks[].waymark.description` | string | yes | — | The glyph in words, for the accessible name. Never the sole carrier of meaning. | | `index` | object | yes | — | Which indexes answered, so a caller can tell a thin result from a missing index. | | `index.lexical` | string | yes | — | The full-text index this searched. | | `index.semantic` | string \| null | yes | — | The vector index, or null when this build has none. | | `index.note` | string | yes | — | What the two lines above mean for these results. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/search?q=session%20cookies&limit=20 ``` ```json { "query": "session cookies", "match_expression": "\"session\" \"cookies\"", "notes": [ { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false } ], "matches": [ { "chunk_id": "chk_2b7e", "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "note_type": "decision", "text": "Session cookies over JWT — chosen for revocation simplicity.", "rank": -1.42, "blocks": [ { "id": "blk_7f3a1b2c", "ref": "kmem://backend/auth-decision#blk_7f3a1b2c", "waymark": { "author_kind": "human", "author": "usr_7c1e4b2a9d3f", "author_name": "ankur", "agent_client": null, "agent_model": null, "on_behalf_of_name": null, "session_id": "sess_9f21", "source": "web", "created_at": "2026-03-14T09:22:00.000Z", "state": "verified", "verified_by": "usr_7c1e4b2a9d3f", "verified_by_name": "ankur", "verified_at": "2026-03-20T11:04:00.000Z", "accepted_by": null, "accepted_by_name": null, "accepted_at": null, "freshness": 0.91, "half_life_days": 180, "past_half_life": false, "description": "Written by ankur. Verified by ankur." } } ] } ], "index": { "lexical": "chunks_fts (SQLite FTS5, BM25)", "semantic": null, "note": "These results are lexical only. The vector index has not been built for this instance yet, so a note that answers the question in different words will not appear here." } } ``` --- ### `POST /api/recall` The ContextPack (PRD 4.3). The same pipeline, config and ports as the MCP `recall` tool, so the recall screen and a connected assistant rank identically. `used_tokens` never exceeds `budget_tokens`, for every budget including `0`. `claims` is never empty alongside an empty `gaps`: when retrieval finds nothing the caller is told so in words, because an agent told nothing invents an answer. A `POST` rather than a `GET` so the question stays out of browser history, proxy logs and `Referer`. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `query` | string | yes | — | The question, in words. Intent classification reads it; no model is called. | | `budget_tokens` | integer | no | `4000` | Ceiling on the pack. `used_tokens` never exceeds it, for every budget including 0. | | `types` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"`[] | no | — | Restrict to these note types. Omit for all of them. | | `since` | integer | no | — | Unix epoch milliseconds. Only notes updated at or after this. | | `include_unverified` | boolean | no | — | kmem ignores this field and keeps it for older clients. Recall can answer from any doc text, whoever wrote it. Recall includes unverified and accepted text, and ranks it lower when the org uses verification. A change that waits in Review is never in an answer. | | `include_superseded` | boolean | no | `false` | `true` returns claims that have been replaced. Answers 'what did we used to do, and why did we change it'. Graph neighbours are still filtered: superseded content comes back when it matches the query, never by being one hop from something that did. | **Response** PRD 4.3, extended by PRD_PART2 F2. The object the MCP `recall` tool returns, plus `doc_provenance` on each claim and `verification`, which the web app draws from. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `query` | string | yes | — | The query as asked, echoed back. | | `budget_tokens` | integer | yes | — | | | `used_tokens` | integer | yes | — | Never greater than `budget_tokens`. | | `claims` | object[] | yes | — | Ranked claims. Empty only alongside a non-empty `gaps`. | | `claims[].ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx`. Resolvable with GET /api/blocks/{id}. | | `claims[].note` | string | yes | — | The note this came from, as its permalink: `folder/slug`, no extension. | | `claims[].kind` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | The type of the note this came from. | | `claims[].heading` | string \| null | yes | — | The innermost heading this claim sits under, or null above the first heading. The enclosing path is the opening line of `text`. | | `claims[].text` | string | yes | — | | | `claims[].version` | string \| null | yes | — | Which version of the note this came from: the first 12 hex characters of its `content_sha`. For display and for noticing that a cited note has moved. Not a concurrency token — `expected_sha` is, it is all 64 characters, and `read` returns it. Not an ordinal — ADR 0011; `GET /api/history/{permalink}` has that. | | `claims[].status` | `"current"` \| `"superseded"` \| `"deprecated"` | yes | — | Where the note is in its life. Not `provenance.state`, which is per block. | | `claims[].updated` | string | yes | — | The note's last modification, ISO-8601 UTC date: `2026-08-13`. | | `claims[].provenance` | object | yes | — | What the status chip beside this **claim** is drawn from (design/DESIGN.md, provenance and status). Per block, and therefore not the same object as `doc_provenance`. | | `claims[].provenance.author_kind` | `"human"` \| `"agent"` | yes | — | | | `claims[].provenance.author` | string \| null | yes | — | | | `claims[].provenance.created_at` | string | yes | — | ISO-8601 UTC. | | `claims[].provenance.state` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `claims[].provenance.trust` | `"unverified"` \| `"accepted"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | How much this claim is trusted. `accepted` means a person read an assistant's words and let them into recall without checking the line; `verified` means a person checked the line itself. | | `claims[].provenance.accepted_at` | string \| null | yes | — | ISO-8601 UTC when a person accepted it, or null. | | `claims[].doc_provenance` | object \| null | yes | — | Who wrote the **doc** this claim came from, and whether anybody has checked it — the same object `GET /api/tree` and `GET /api/notes/{permalink}` carry, so a source chip and a browse row say the same thing about one doc. `null` when the doc is gone or outside this reader's scope, which is also the only shape in which a claim can outlive its doc. | | `claims[].freshness` | number | yes | — | `exp(-age_days / half_life)` for the note type, in (0, 1]. | | `claims[].score` | number | yes | — | Final rank after fusion, graph expansion, reranking and adjustment. | | `claims[].related` | string[] | yes | — | One-hop neighbours worth reading next, as `kmem://` refs. | | `claims[].truncated` | `true` | no | — | Present only when `text` was cut to fit the budget. | | `conflicts` | object[] | yes | — | Unresolved disagreements among the retrieved claims. | | `conflicts[].topic` | string | yes | — | | | `conflicts[].refs` | string[] | yes | — | At least two refs that disagree. | | `conflicts[].note` | string | yes | — | A one-line summary of the disagreement. | | `gaps` | string[] | yes | — | What the knowledge base does not know, in words. Never empty when `claims` is empty — an agent told nothing invents an answer. | | `index_health` | object | yes | — | | | `index_health.embedding_coverage` | number | yes | — | Fraction of committed chunks embedded, in [0, 1]. | | `index_health.lag_seconds` | number | yes | — | Age of the oldest unindexed commit. 0 when caught up. | | `budget` | object | yes | — | PRD_PART2 F2. `requested` and `used` restate `budget_tokens` and `used_tokens` — those names are published and were not renamed — and `report` and `dropped` are what the flat pair could not say. | | `budget.requested` | integer | yes | — | The ceiling that was enforced. `budget_tokens`. | | `budget.used` | integer | yes | — | What the claims cost. `used_tokens`. | | `budget.report` | integer | yes | — | What `conflicts` and `gaps` cost. Measured the same way and charged to nothing, so the response is larger than `used` by this much. | | `budget.dropped` | integer | yes | — | How many ranked candidates did not fit. | | `partial` | boolean | yes | — | Whether this pack is less than everything that matched: a candidate did not fit, a claim was cut, or a stage was cut short. `false` says the ranking was exhausted, not that the corpus was. | | `verification` | `"on"` \| `"off"` | yes | — | Whether the org uses verification (`docs/ADR/0018`), which is the mode this pack was ranked in. `on` means verified claims ranked above accepted ones, and accepted above unverified ones. `off` means they ranked the same, and a client draws no Unverified, Accepted or Verified mark. Neither mode holds a claim back (`docs/ADR/0027`). A deployment with no org reads `on`. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http POST /api/recall ``` ```json { "query": "what did we decide about auth", "budget_tokens": 4000 } ``` ```json { "query": "what did we decide about auth", "budget_tokens": 4000, "used_tokens": 0, "claims": [], "conflicts": [], "gaps": [ "The search index has not been built for this instance yet. No claims can be returned." ], "index_health": { "embedding_coverage": 0, "lag_seconds": 0 }, "budget": { "requested": 4000, "used": 0, "report": 21, "dropped": 0 }, "partial": false, "verification": "off" } ``` --- ### `POST /api/orgs` Create an organization; the caller becomes its owner. The self-service path: a signed-in person names an organization, chooses a subdomain — the label under SIGNPOST_ROOT_DOMAIN it is reached at, "acme" for acme.kmem.app — and becomes its owner. Refuses a reserved subdomain (`@signpost/core/tenancy`'s `RESERVED_SUBDOMAINS` — "www", "api", "default" and their kin) before anything is written, and a subdomain another organization already holds by the unique constraints inside the one atomic batch — nothing persists in either case. Provisions the organization, its `org_routing` row, the creator's own owner membership and the organization's first project together, in one D1 batch, so an organization no member can reach or a project pointing at an organization that does not exist are both impossible. The response does not make the new organization active on its own — the web app calls `/api/auth/organization/set-active` immediately afterward, the same way switching between existing organizations does. `agent_writes` and `verification` are the two choices the create form shows; each is `off` unless the creator sends `on`, and `organization_created` at warn records both. **Success:** `201` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | string | yes | — | The organization's name. Shown in Organizations and on every membership row. | | `subdomain` | string | yes | — | The label under SIGNPOST_ROOT_DOMAIN this organization is reached at — "acme" for acme.kmem.app. Case-insensitive on the way in; stored lowercase. Lowercase letters, digits and interior hyphens only, and not one of the reserved words in @signpost/core/tenancy ("www", "api", "default", and their kin). | | `agent_writes` | `"off"` \| `"on"` | no | `"off"` | Whether a connection in the new org may change docs without a person in Review. `off` (the default): agents read and propose, and every change waits in Review. `on`: a connection made with `write` scope adds docs and edits the current version directly (ADR 0022). | | `verification` | `"on"` \| `"off"` | no | `"off"` | Whether the new org tracks who wrote each fact and its state (ADR 0027). `off` (the default): docs show no fact states, and answers treat every fact the same. `on`: docs show who wrote each fact and its state, and answers rank verified facts first. Accepted and unverified facts still appear in answers. In both modes, a change that waits in Review never appears in answers. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `organization` | object | yes | — | | | `organization.id` | string | yes | — | | | `organization.name` | string | yes | — | | | `organization.slug` | string | yes | — | | | `organization.subdomain` | string | yes | — | | | `organization.created_at` | string | yes | — | ISO-8601 UTC. | | `project` | object | yes | — | | | `project.id` | string | yes | — | | | `project.org_id` | string | yes | — | | | `project.slug` | string | yes | — | | | `project.name` | string | yes | — | | | `project.created_at` | string | yes | — | ISO-8601 UTC. | | `project.updated_at` | string | yes | — | ISO-8601 UTC. | | `member` | object | yes | — | | | `member.id` | string | yes | — | | | `member.organization_id` | string | yes | — | | | `member.user_id` | string | yes | — | | | `member.role` | `"owner"` | yes | — | Always owner: the creator of an organization is always its owner. | | `member.created_at` | string | yes | — | ISO-8601 UTC. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-subdomain` | protocol | `400` | The subdomain is not lowercase letters, digits and interior hyphens, 3 to 63 characters. | | `reserved-subdomain` | protocol | `400` | The subdomain is reserved for kmem itself, or is the string "default" specifically. | | `subdomain-taken` | protocol | `400` | Another organization already holds this subdomain. | **Example** ```http POST /api/orgs ``` ```json { "name": "Acme", "subdomain": "acme", "agent_writes": "off", "verification": "off" } ``` ```json { "organization": { "id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Acme", "slug": "acme", "subdomain": "acme", "created_at": "2026-08-19T09:12:44.000Z" }, "project": { "id": "proj_6f1c9b0a4d1e4a3f8c2b7e5d9a0f3c22", "org_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "slug": "default", "name": "Acme", "created_at": "2026-08-19T09:12:44.000Z", "updated_at": "2026-08-19T09:12:44.000Z" }, "member": { "id": "mem_8a2c9b0a4d1e4a3f8c2b7e5d9a0f3c33", "organization_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "user_id": "KJqwHRHKWj8nqeiyUMec56wUJ6m8bViR", "role": "owner", "created_at": "2026-08-19T09:12:44.000Z" } } ``` --- ### `GET /api/orgs/availability` Whether a subdomain is free, as a 200. What the create-organization form asks on every pause in typing. A taken address is an ordinary answer, `available: false`, never a `4xx`: better-auth's `check-slug` answered `400` for it, and every check printed an error in the browser console. The check applies everything `POST /api/orgs` refuses before it writes, through the same rule and in the same words — 3 to 63 lowercase letters, digits and interior hyphens, no `--` in the third and fourth characters, none of the reserved words in `@signpost/core/tenancy`, no organization routing at it or holding it as its slug, and no thirty-day hold a deleted organization left for another account. `reason` never names who holds an address. A taken or reserved label gets a `suggestion` that was free when checked. Advisory: two people can check one free address in the same second, and only one create wins. Open at the hosted apex beside `POST /api/orgs`, and bounded per person — 60 checks a minute and 1,000 an hour — so it is not a directory of organizations. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `subdomain` | string | yes | — | The label to check, as typed — "acme" for acme.kmem.app. Trimmed and lower-cased first, exactly as `POST /api/orgs` stores it. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `subdomain` | string | yes | — | What was checked: the query, trimmed and lower-cased. | | `available` | boolean | yes | — | `true` when `POST /api/orgs` would accept this subdomain from this account now. Advisory: two people can check one free address in the same second, and only one create wins. | | `reason` | string | no | — | Why not, cause then fix, in a sentence a person can read — the words `POST /api/orgs` refuses with. Never names who holds an address. Absent when `available` is true. | | `suggestion` | string | no | — | A subdomain built from this one that was free when checked. Offered only when this one is taken or reserved; a malformed label gets a reason and no guess. | | `code` | `"org-limit-reached"` | no | — | Present when no subdomain would do: `org-limit-reached` means this account already owns the most organizations one account may own, the refusal `POST /api/orgs` answers with the same code. `reason` carries its sentence. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `rate-limited` | retryable | `429` | This person has made 60 address checks in the past minute, or 1,000 in the past hour. `Retry-After` says when to ask again. | **Example** ```http GET /api/orgs/availability?subdomain=acme ``` ```json { "subdomain": "acme", "available": false, "reason": "This address is taken. Choose another subdomain.", "suggestion": "acme-team" } ``` --- ### `PATCH /api/orgs/{orgId}` Rename an organization. An owner or an admin only. The one editable field of an organization's identity. The subdomain is not here and cannot be sent: this schema is strict, so a body naming `subdomain` is refused by the parser rather than ignored, and `auth/config.ts`'s `beforeUpdateOrganization` refuses a `slug` change on better-auth's own path for the same reason — the subdomain is the address every connection, every agent briefing and every AGENTS.md a customer has already pasted into a repository points at. `{orgId}` has to be the organization this session's own request resolved to. The role is read off the caller's `member` row rather than off the session, because `/api` collapses the stored role into the read ladder's two rungs and an admin reads as a member there. Emits `organization_renamed` at warn with both names and the person's id. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id, from `POST /api/orgs`'s own response. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | string | yes | — | The organization's name. Shown in navigation and in every invitation. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `organization` | object | yes | — | | | `organization.id` | string | yes | — | | | `organization.name` | string | yes | — | | | `organization.slug` | string | yes | — | | | `organization.subdomain` | string | yes | — | | | `organization.created_at` | string | yes | — | ISO-8601 UTC. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | | `forbidden` | protocol | `403` | The caller's member row names neither owner nor admin in this organization. | **Example** ```http PATCH /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11 ``` ```json { "name": "Acme Corp" } ``` ```json { "organization": { "id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Acme Corp", "slug": "acme", "subdomain": "acme", "created_at": "2026-08-19T09:12:44.000Z" } } ``` --- ### `GET /api/orgs/{orgId}/verification` Whether this org uses verification. `docs/ADR/0018` and `docs/ADR/0027`. Verification tracks who wrote each fact, a person or an agent, and whether a person has checked it. With `on`, the app shows each fact's state, and `recall`, Quick Connect and Ask rank each claim by its trust rung and author kind: verified first, then accepted, then unverified. Accepted and unverified facts still appear in answers. With `off`, the weights are all 1 and the app shows no fact states. Stale and superseded content keep their handling in both modes, and Review for an assistant's change to an existing doc stays in both. A change that waits in Review never appears in answers. A new org starts `off`. Any member of the org may read this. `{orgId}` has to be the org this request resolved to; a self-hosted deployment has no org and always reads `on`. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id, from `POST /api/orgs`'s own response. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `verification` | `"on"` \| `"off"` | yes | — | `on`: the app shows who wrote each fact and its state, and answers rank verified facts first. Accepted and unverified facts still appear in answers. `off`: every fact ranks the same and the app shows no fact states. In both modes, a change that waits in Review never appears in answers. | | `can_change` | boolean | yes | — | Whether this person may change the setting: an owner or an admin of the org. Everybody else reads it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | **Example** ```http GET /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/verification ``` ```json { "verification": "off", "can_change": true } ``` --- ### `PATCH /api/orgs/{orgId}/verification` Turn verification on or off for an org. An owner or an admin only. `docs/ADR/0018`. Writes one `org_routing` column and nothing else: the change accepts nothing, verifies nothing and rewrites no provenance row. It changes how answers rank each fact and which marks the app shows, and it removes no doc from answers. The role is read off the caller's `member` row, as for a rename. Emits `org_verification_changed` at warn with the person's id and both values, and `org_verification_refused` at warn when the role is wrong. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id, from `POST /api/orgs`'s own response. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `verification` | `"on"` \| `"off"` | yes | — | `on`: the app shows who wrote each fact and its state, and answers rank verified facts first. Accepted and unverified facts still appear in answers. `off`: every fact ranks the same and the app shows no fact states. In both modes, a change that waits in Review never appears in answers. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `verification` | `"on"` \| `"off"` | yes | — | `on`: the app shows who wrote each fact and its state, and answers rank verified facts first. Accepted and unverified facts still appear in answers. `off`: every fact ranks the same and the app shows no fact states. In both modes, a change that waits in Review never appears in answers. | | `can_change` | boolean | yes | — | Whether this person may change the setting: an owner or an admin of the org. Everybody else reads it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | | `forbidden` | protocol | `403` | The caller's member row names neither owner nor admin in this organization. | **Example** ```http PATCH /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/verification ``` ```json { "verification": "on" } ``` ```json { "verification": "on", "can_change": true } ``` --- ### `GET /api/orgs/{orgId}/agent-writes` Whether a connection in this org may change docs without a person in Review. `docs/ADR/0022-agent-writes.md`. With `off`, every change from an agent waits in Review for a person: a connection can read, or read and propose, and a connection made with `write` scope acts as `suggest`. With `on`, a connection made with `write` scope adds docs and edits the current version directly; a deletion, an edit to an older version and a doc that replaces a live doc still wait in Review. A new org starts `off`, and every org that existed before 2026-09-16 was set to `off`. `write_connections` counts the active connections the setting affects. `limits` names each reason a write connection would still propose with the setting on. Any member of the org may read this. `{orgId}` has to be the org this request resolved to; a self-hosted deployment has no org and no setting. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id, from `POST /api/orgs`'s own response. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `agent_writes` | `"off"` \| `"on"` | yes | — | `off`: every change from an agent waits in Review for a person. `on`: a connection with write scope adds docs and edits the current version directly; deletions and edits to an older version still wait in Review. | | `write_connections` | integer | yes | — | How many active connections in this org were made with write scope. They act as `suggest` while the setting is off, and change docs directly while it is on. | | `can_change` | boolean | yes | — | Whether this person may change the setting: an owner or an admin of the org. Everybody else reads it. | | `limits` | string[] | yes | — | One sentence for each reason a write connection would still propose with the setting on: an org or project default below `write`, or a deployment write policy of `review` or `locked`. Empty is the common answer. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | **Example** ```http GET /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/agent-writes ``` ```json { "agent_writes": "off", "write_connections": 2, "can_change": true, "limits": [] } ``` --- ### `PATCH /api/orgs/{orgId}/agent-writes` Turn agent writes on or off for an org. An owner or an admin only. `docs/ADR/0022-agent-writes.md`. Writes one `org_routing` column and nothing else: no connection is rewritten, and the next request of each connection reads the new value. Turning it off sends every later agent change to Review at once; changes already applied stay, and undo by session reverses them. The role is read off the caller's `member` row, as for a rename. Emits `org_agent_writes_changed` at warn with the person's id, both values and `write_connections`, and `org_agent_writes_refused` at warn when the role is wrong. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id, from `POST /api/orgs`'s own response. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `agent_writes` | `"off"` \| `"on"` | yes | — | `off`: every change from an agent waits in Review for a person. `on`: a connection with write scope adds docs and edits the current version directly; deletions and edits to an older version still wait in Review. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `agent_writes` | `"off"` \| `"on"` | yes | — | `off`: every change from an agent waits in Review for a person. `on`: a connection with write scope adds docs and edits the current version directly; deletions and edits to an older version still wait in Review. | | `write_connections` | integer | yes | — | How many active connections in this org were made with write scope. They act as `suggest` while the setting is off, and change docs directly while it is on. | | `can_change` | boolean | yes | — | Whether this person may change the setting: an owner or an admin of the org. Everybody else reads it. | | `limits` | string[] | yes | — | One sentence for each reason a write connection would still propose with the setting on: an org or project default below `write`, or a deployment write policy of `review` or `locked`. Empty is the common answer. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | | `forbidden` | protocol | `403` | The caller's member row names neither owner nor admin in this organization. | **Example** ```http PATCH /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/agent-writes ``` ```json { "agent_writes": "on" } ``` ```json { "agent_writes": "on", "write_connections": 2, "can_change": true, "limits": [] } ``` --- ### `DELETE /api/orgs/{orgId}` Delete an organization. The owner only, and in five ordered steps. Revokes every connection, rejects every pending proposal, tombstones every live note through `CommitService.commit({op:'delete'})`, and then removes the control-plane rows — team memberships, teams, invitations, memberships, `org_routing`, `project` and `organization` — in one D1 batch. The order is the control: a connection revoked last could write into an organization being removed, and rows removed first would orphan a knowledge base. Invariant I2 holds throughout — every note gets an explicit tombstone and the R2 blobs stay — and `audit_log` is left standing, because deleting the record of a deletion is the one write nobody could review afterwards. Each of the first three steps is paged and this request has a budget, so an organization larger than the budget answers 202 with `state: "in-progress"`; send the same request again to continue, or read `status_url` first. Every step is idempotent. It does not re-authenticate the caller: no route on this deployment reports when a session last proved itself, so the typed org address in the settings dialog is a check on intent and not on identity. Emits `organization_deleted` at warn with a count per table. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `state` | `"deleted"` \| `"in-progress"` | yes | — | `deleted` when the organization's rows are gone and nothing is left to do. `in-progress` when this request spent its budget and work remains — send the same DELETE again, or read `status_url` first. | | `org_id` | string | yes | — | | | `revoked_tokens` | integer | yes | — | Connections revoked by this request. | | `rejected_proposals` | integer | yes | — | Pending proposals decided as rejected by this request. | | `deleted_notes` | integer | yes | — | Notes tombstoned by this request. The R2 blobs behind them stay (invariant I2). | | `remaining` | object | yes | — | What this request did not reach. All three are 0 when `state` is `deleted`. | | `remaining.tokens` | integer | yes | — | | | `remaining.proposals` | integer | yes | — | | | `remaining.notes` | integer | yes | — | | | `status_url` | string | yes | — | Where to read whether the deletion finished, without asking for it again. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | | `forbidden` | protocol | `403` | The caller's member row does not name owner in this organization. | **Example** ```http DELETE /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11 ``` ```json { "state": "deleted", "org_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "revoked_tokens": 2, "rejected_proposals": 1, "deleted_notes": 17, "remaining": { "tokens": 0, "proposals": 0, "notes": 0 }, "status_url": "/api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/deletion" } ``` --- ### `GET /api/orgs/{orgId}/deletion` Whether a deletion finished, without asking for it again. What the settings dialog's "Check status" reads after a request whose answer never arrived. It writes nothing, which is the whole point: a person checking on a deletion they did not see the end of must not be able to start a second one by pressing a button. `deleted` means no `organization` row remains; `pending` means the row remains and links, proposals or notes are still standing; `ready` means the row remains and nothing else does, so one more DELETE finishes it. Those three are facts this endpoint observes — it cannot say whether a deletion is under way. Any member may read it: three counts, no note, no address and no person. `deleted` is the defensive arm rather than the ordinary path — a completed deletion removes the caller's own member row, so the next request is refused with 403 by the membership gate and, once the host-to-organization memo expires 30 s later, with 404 by the tenant middleware. Both mean the organization is gone. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `state` | `"deleted"` \| `"pending"` \| `"ready"` | yes | — | `deleted` when no organization row remains. `pending` when the row remains and notes, proposals or links are still standing. `ready` when the row remains and nothing else does, so one more DELETE finishes it. These three are facts this endpoint can observe; it cannot say whether a deletion is under way, only what is left. | | `org_id` | string | yes | — | | | `remaining` | object | yes | — | | | `remaining.tokens` | integer | yes | — | | | `remaining.proposals` | integer | yes | — | | | `remaining.notes` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | **Example** ```http GET /api/orgs/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/deletion ``` ```json { "state": "pending", "org_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "remaining": { "tokens": 0, "proposals": 0, "notes": 12 } } ``` --- ### `GET /api/org/{orgId}/projects` Every project in an organization. `insertProject` and `listProjectsForOrg` (`tenancy/store.ts`) get their first route here: `{orgId}` has to be the organization this session's own request already resolved to — see the one error below — so there is no second, path-supplied organization id for anything to be pointed at another organization with. Oldest first, `listProjectsForOrg`'s own order. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id, from `POST /api/orgs`'s own response. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `projects` | object[] | yes | — | | | `projects[].id` | string | yes | — | | | `projects[].org_id` | string | yes | — | | | `projects[].slug` | string | yes | — | | | `projects[].name` | string | yes | — | | | `projects[].created_at` | string | yes | — | ISO-8601 UTC. | | `projects[].updated_at` | string | yes | — | ISO-8601 UTC. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | **Example** ```http GET /api/org/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/projects ``` ```json { "projects": [ { "id": "proj_6f1c9b0a4d1e4a3f8c2b7e5d9a0f3c22", "org_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "slug": "default", "name": "Acme", "created_at": "2026-08-19T09:12:44.000Z", "updated_at": "2026-08-19T09:12:44.000Z" } ] } ``` --- ### `POST /api/org/{orgId}/projects` Create another project inside an organization. An organization's first project is provisioned by `POST /api/orgs` itself, slug `default`; this is the door for every project after it. `slug` is unique within the organization (`project_org_id_slug_idx`) and is re-validated against the shape `slugFromTitle` (`@signpost/core/ref`) already produces client-side — the same lowercase-letters-digits-and-interior-hyphens rule `POST /api/orgs`'s own subdomain answers to. **Success:** `201` **Path parameters** | Name | Description | | --- | --- | | `orgId` | The organization's id. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | string | yes | — | The project's name. | | `slug` | string | yes | — | The project's address segment, unique within the organization. The client derives this with slugFromTitle (@signpost/core/ref); this route re-validates it rather than trusting it. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | string | yes | — | | | `org_id` | string | yes | — | | | `slug` | string | yes | — | | | `name` | string | yes | — | | | `created_at` | string | yes | — | ISO-8601 UTC. | | `updated_at` | string | yes | — | ISO-8601 UTC. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-organization` | protocol | `404` | {orgId} is not the organization this session's own request resolved to. | | `invalid-slug` | protocol | `400` | The slug is not lowercase letters, digits and interior hyphens. | | `slug-taken` | protocol | `400` | This organization already has a project with this slug. | **Example** ```http POST /api/org/org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/projects ``` ```json { "name": "Backend docs", "slug": "backend-docs" } ``` ```json { "id": "proj_7a2c9b0a4d1e4a3f8c2b7e5d9a0f3c44", "org_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "slug": "backend-docs", "name": "Backend docs", "created_at": "2026-08-19T09:14:02.000Z", "updated_at": "2026-08-19T09:14:02.000Z" } ``` --- ### `POST /api/ask` A person asks their docs a question, answered in prose. Ask answers as the signed-in person, with their own read scope (invariant I7): one retrieval, one model call, no write. `Accept: text/event-stream` streams the answer — a `sources` event first, then zero or more `delta` events, then one `done`; an `error` event replaces the rest and closes the stream. Any other `Accept` returns the same object as `done` plus `sources`. The answer is prose in sentence case and carries no ref, score or rank. `grounded: false` comes with a `gap` saying why the docs did not answer. A model that wrote no answer at all is an `error`, never a gap. Not the in-app assistant: that answers as an agent principal and returns a tool trace. See docs/ASK.md. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `question` | string | yes | — | The question, in plain words. | | `history` | object[] | no | `[]` | The recent exchange, oldest first. The server keeps no conversation. | | `history[].role` | `"user"` \| `"assistant"` | yes | — | | | `history[].text` | string | yes | — | | | `conversation_id` | string | no | — | The client's own id for this conversation. Echoed on the log line. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `answer` | string | yes | — | | | `grounded` | boolean | yes | — | Whether the answer rests on the docs. False means `gap` says why they did not. | | `cited` | string[] | yes | — | Refs the answer rests on. Always a subset of `sources`. | | `followups` | string[] | yes | — | | | `gap` | object \| null | yes | — | | | `model` | string | yes | — | | | `truncated` | boolean | yes | — | The model stopped at its output cap. The answer then ends with an ellipsis. | | `sources` | object[] | yes | — | | | `sources[].ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx` — the best-ranked claim this doc contributed. | | `sources[].permalink` | string | yes | — | The address the chip links to: `folder/slug`. | | `sources[].title` | string | yes | — | | | `sources[].folder` | string | yes | — | `` for the top level. | | `sources[].heading` | string \| null | yes | — | | | `sources[].status` | `"verified"` \| `"accepted"` \| null | yes | — | Null for a human-authored doc nobody has verified. The UI then shows only a provenance word. | | `sources[].written_by` | string \| null | yes | — | | | `sources[].uploaded_by` | string \| null | yes | — | | | `sources[].agent_name` | string \| null | yes | — | The agent that wrote the doc, by its connection's name. Null for a person's doc. | | `sources[].agent_owner` | string \| null | yes | — | The person who owns that agent's connection, by name. Never an account id. | | `sources[].snippet` | string | yes | — | A short extract, for the chip. The model reads the whole passage retrieval kept, never this. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `ask-unavailable` | retryable | `503` | No model could be reached for this question. The message names the cause; the fix names the Workers AI binding and SIGNPOST_MODEL_ASK. | **Example** ```http POST /api/ask ``` ```json { "question": "How long do sessions last?", "history": [], "conversation_id": "conv_9c1d7b2a4e3f4c8a" } ``` ```json { "answer": "Sessions expire 24 hours after they are issued, and a refresh extends them by another 24.", "grounded": true, "cited": [ "kmem://backend/session-expiry#blk_1a2b3c4d" ], "followups": [ "What happens to an agent's connection?" ], "gap": null, "model": "@cf/zai-org/glm-5.3-flash", "truncated": false, "sources": [ { "ref": "kmem://backend/session-expiry#blk_1a2b3c4d", "permalink": "backend/session-expiry", "title": "Session expiry", "folder": "backend", "heading": "Timeouts", "status": "verified", "written_by": "Ankur", "uploaded_by": null, "agent_name": null, "agent_owner": null, "snippet": "A session expires 24 hours after it is issued." } ] } ``` --- ### `GET /api/ask/starters` Three to five starter questions for an empty Ask screen. Drawn from the titles of the twelve docs this organization updated most recently, each linked to the doc it came from. When the model call fails, the set is topped up with one plain question per recent doc. An organization with no docs gets none. The one GET under /api that is rate limited, because it is the one GET that spends a model call. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `starters` | object[] | yes | — | | | `starters[].question` | string | yes | — | | | `starters[].ref` | string \| null | yes | — | `kmem://permalink` of the doc it was drawn from, or null when the model named a title that is not one of the docs. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `ask-unavailable` | retryable | `503` | No model could be reached for this question. The message names the cause; the fix names the Workers AI binding and SIGNPOST_MODEL_ASK. | **Example** ```http GET /api/ask/starters ``` ```json { "starters": [ { "question": "How long do sessions last?", "ref": "kmem://backend/session-expiry" }, { "question": "Why did we choose a Durable Object for rate limiting?", "ref": "kmem://backend/rate-limits" }, { "question": "What does \"Review handbook\" say?", "ref": "kmem://review/handbook" } ] } ``` --- ### `POST /api/assistant/ask` One question, one answer, cited. The in-app assistant, off at launch behind `LAUNCH_FLAGS.assistant`. Authenticated by the signed-in person's session cookie, exactly like every other `/api` route, but the thing answering is an assistant: it is recorded and clamped as one — a synthetic session with `author_kind: agent`, a read scope equal to the person's own (ADR 0026), one session id per conversation — and every read and every write it makes goes through `invokeTool`, the same dispatcher `/mcp` and `/api/webmcp/call` use. Stateless on this side of the wire: the caller mints `conversation_id` client-side and resends the short `history` window it wants considered on every call; there is no conversation store yet (`assistant.md` §11). **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `conversation_id` | string | yes | — | | | `message` | string | yes | — | | | `context_ref` | string | no | — | | | `history` | object[] | no | `[]` | | | `history[].role` | `"user"` \| `"assistant"` | yes | — | | | `history[].text` | string | yes | — | | | `general_knowledge` | boolean | no | `true` | | | `budget_tokens` | integer | no | `4000` | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `conversation_id` | string | yes | — | | | `trace` | object[] | yes | — | | | `trace[].verb` | `"recall"` \| `"read"` \| `"browse"` \| `"remember"` \| `"edit"` \| `"verify"` \| `"link"` | yes | — | | | `trace[].argument` | string | yes | — | `recall` shows the query; `read` shows the ref; and so on. | | `trace[].outcome` | `"ok"` \| `"error"` | yes | — | | | `trace[].tokens` | object | no | — | Present only for `recall`, the one verb the trace shows a budget for. | | `trace[].tokens.used` | integer | yes | — | | | `trace[].tokens.budget` | integer | yes | — | | | `claims` | object[] | yes | — | | | `claims[].ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx`. Resolvable with GET /api/blocks/{id}. | | `claims[].note` | string | yes | — | The note this came from, as its permalink: `folder/slug`, no extension. | | `claims[].kind` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | The type of the note this came from. | | `claims[].heading` | string \| null | yes | — | The innermost heading this claim sits under, or null above the first heading. The enclosing path is the opening line of `text`. | | `claims[].text` | string | yes | — | | | `claims[].version` | string \| null | yes | — | Which version of the note this came from: the first 12 hex characters of its `content_sha`. For display and for noticing that a cited note has moved. Not a concurrency token — `expected_sha` is, it is all 64 characters, and `read` returns it. Not an ordinal — ADR 0011; `GET /api/history/{permalink}` has that. | | `claims[].status` | `"current"` \| `"superseded"` \| `"deprecated"` | yes | — | Where the note is in its life. Not `provenance.state`, which is per block. | | `claims[].updated` | string | yes | — | The note's last modification, ISO-8601 UTC date: `2026-08-13`. | | `claims[].provenance` | object | yes | — | What the status chip beside this **claim** is drawn from (design/DESIGN.md, provenance and status). Per block, and therefore not the same object as `doc_provenance`. | | `claims[].provenance.author_kind` | `"human"` \| `"agent"` | yes | — | | | `claims[].provenance.author` | string \| null | yes | — | | | `claims[].provenance.created_at` | string | yes | — | ISO-8601 UTC. | | `claims[].provenance.state` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `claims[].provenance.trust` | `"unverified"` \| `"accepted"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | How much this claim is trusted. `accepted` means a person read an assistant's words and let them into recall without checking the line; `verified` means a person checked the line itself. | | `claims[].provenance.accepted_at` | string \| null | yes | — | ISO-8601 UTC when a person accepted it, or null. | | `claims[].doc_provenance` | object \| null | yes | — | Who wrote the **doc** this claim came from, and whether anybody has checked it — the same object `GET /api/tree` and `GET /api/notes/{permalink}` carry, so a source chip and a browse row say the same thing about one doc. `null` when the doc is gone or outside this reader's scope, which is also the only shape in which a claim can outlive its doc. | | `claims[].freshness` | number | yes | — | `exp(-age_days / half_life)` for the note type, in (0, 1]. | | `claims[].score` | number | yes | — | Final rank after fusion, graph expansion, reranking and adjustment. | | `claims[].related` | string[] | yes | — | One-hop neighbours worth reading next, as `kmem://` refs. | | `claims[].truncated` | `true` | no | — | Present only when `text` was cut to fit the budget. | | `conflicts` | object[] | yes | — | | | `conflicts[].topic` | string | yes | — | | | `conflicts[].refs` | string[] | yes | — | At least two refs that disagree. | | `conflicts[].note` | string | yes | — | A one-line summary of the disagreement. | | `gaps` | string[] | yes | — | | | `answer` | string \| null | yes | — | `null` only when nothing in the pack supported one — the full-gap case. | | `outside_knowledge_base` | string \| null | yes | — | The general-knowledge section, present only when it was asked for and offered. | | `used_tokens` | integer | yes | — | | | `budget_tokens` | integer | yes | — | | | `partial` | boolean | yes | — | | | `withheld` | object[] | yes | — | | | `withheld[].folder` | string | yes | — | The folder, "" for the top level. | | `withheld[].count` | integer | yes | — | How many notes in it this principal may not read. | | `tool_error` | string \| null | yes | — | Set only when a verb itself failed, as distinct from the notes not answering the question. | | `model` | string \| null | yes | — | `null` on a `tool_error` turn, where no model call was made. | | `spend` | object | yes | — | | | `spend.calls` | integer | yes | — | | | `spend.reported` | integer | yes | — | Calls whose response carried any cost field. `calls` minus this is the gap. | | `spend.usd_reported` | integer | yes | — | Calls whose response carried a `usd` figure specifically. | | `spend.input_tokens` | integer | yes | — | | | `spend.output_tokens` | integer | yes | — | | | `spend.usd` | number | yes | — | US dollars, summed over the calls that reported one. Never estimated. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `assistant-unavailable` | application | `503` | No model provider is configured, or the upstream refused the call. An operator must set OPENROUTER_API_KEY (or the provider named by SIGNPOST_ASSISTANT_PROVIDER) with `wrangler secret put` and redeploy. | **Example** ```http POST /api/assistant/ask ``` ```json { "conversation_id": "asst_9c1d7b2a4e3f4c8a", "message": "What did we decide about auth?", "history": [], "general_knowledge": true } ``` ```json { "conversation_id": "asst_9c1d7b2a4e3f4c8a", "trace": [ { "verb": "recall", "argument": "what did we decide about auth", "outcome": "ok", "tokens": { "used": 210, "budget": 4000 } } ], "claims": [ { "ref": "kmem://backend/auth-decision#blk_7f3a1b2c", "note": "backend/auth-decision", "kind": "decision", "heading": "Auth decision", "text": "Session cookies over JWT — chosen for revocation simplicity.", "version": "9f2a1c7d5e3b", "status": "current", "updated": "2026-03-20", "provenance": { "author_kind": "human", "author": "ankur", "created_at": "2026-03-14T09:22:00.000Z", "state": "verified", "trust": "verified", "accepted_at": null }, "doc_provenance": { "author_kind": "human", "written_by": "ankur", "uploaded_by": null, "accepted_by": null, "verified_by": "ankur", "verified_at": "2026-03-20T11:04:00.000Z", "status": "verified", "mixed_authorship": false, "pending_proposals": 0, "agent_name": null, "agent_owner": null }, "freshness": 0.91, "score": 8.42, "related": [ "kmem://backend/session-store" ] } ], "conflicts": [], "gaps": [], "answer": "Session cookies over JWT, chosen for revocation simplicity.", "outside_knowledge_base": null, "used_tokens": 210, "budget_tokens": 4000, "partial": false, "withheld": [], "tool_error": null, "model": "deepseek/deepseek-v4-flash-0731", "spend": { "calls": 1, "reported": 1, "usd_reported": 1, "input_tokens": 640, "output_tokens": 48, "usd": 0.00021 } } ``` --- ### `POST /api/assistant/write` One proposed change, through the same clamp as any assistant. `remember`, `edit`, `verify` or `link`, dispatched through the identical clamp every other assistant's write goes through: `CommitService.commitOrQueue` behind `writeDispositionForCapability`. Nothing in this route decides apply, queue or refuse — it only reads what the clamp already decided and reshapes it into one of the three outcome cards `assistant.md` §6 describes. A malformed argument comes back as a `"refuse"` card naming the field, not a thrown error. `verify` always applies: it stamps provenance and changes no note text, so there is no diff for a reviewer to accept. Refused before any of that runs when this organization's plan does not include the assistant at all. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `conversation_id` | string | yes | — | | | `verb` | `"remember"` \| `"edit"` \| `"verify"` \| `"link"` | yes | — | | | `args` | object | no | `{}` | | **Response** *When `disposition` is "apply":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `disposition` | `"apply"` | yes | — | | | `because` | string | yes | — | | | `ref` | string \| null | yes | — | | *When `disposition` is "queue":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `disposition` | `"queue"` | yes | — | | | `because` | string | yes | — | | | `ref` | string \| null | yes | — | | | `proposal_id` | string | yes | — | | *When `disposition` is "refuse":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `disposition` | `"refuse"` | yes | — | | | `because` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http POST /api/assistant/write ``` ```json { "conversation_id": "asst_9c1d7b2a4e3f4c8a", "verb": "remember", "args": { "content": "Rotate the signing key every 90 days.", "kind": "decision", "folder": "backend" } } ``` ```json { "disposition": "queue", "because": "An assistant's write is queued for review rather than applied directly.", "ref": "kmem://backend/rotate-the-signing-key-every-90-days", "proposal_id": "prop_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5" } ``` --- ### `GET /api/billing/entitlement` This organization's plan, its limits, and its usage so far. What tier `c.var.project.projectId` — the organization id in hosted mode — is on, what that tier allows, and how much of it is already used. Every organization reads back `"free"` today, honestly, rather than as a stub: no checkout and no webhook are wired in this wave, so there is no `POST` here and no route for the enterprise tier beyond the contact address the button needs. Readable by any signed-in member — the same bar `GET /api/audit` and `GET /api/tokens` already set — because what plan an organization is on is not a secret from anyone inside it. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `org_id` | string | yes | — | | | `tier` | `"free"` \| `"paid"` \| `"enterprise"` | yes | — | | | `tier_source` | `"default-free"` \| `"manual-grant"` \| `"billing-provider"` | yes | — | | | `limits` | object | yes | — | | | `limits.max_collaborators` | integer \| null | yes | — | | | `limits.max_projects` | integer \| null | yes | — | | | `limits.approx_max_notes` | integer \| null | yes | — | | | `limits.assistant_included` | boolean | yes | — | | | `usage` | object | yes | — | | | `usage.collaborators` | integer | yes | — | | | `usage.projects` | integer | yes | — | | | `usage.spend` | object \| null | yes | — | | | `enterprise_contact` | object | yes | — | | | `enterprise_contact.email` | string | yes | — | | | `enterprise_contact.subject` | string | yes | — | | | `enterprise_contact.body` | string | yes | — | | | `upgrade` | object | yes | — | | | `upgrade.checkout_available` | boolean | yes | — | | | `upgrade.provider` | string \| null | yes | — | | | `upgrade.grant_variable` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/billing/entitlement ``` ```json { "org_id": "org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "tier": "free", "tier_source": "default-free", "limits": { "max_collaborators": 2, "max_projects": 1, "approx_max_notes": 100, "assistant_included": false }, "usage": { "collaborators": 1, "projects": 1, "spend": { "total_usd_micros": 58, "line_usd_micros": 10000, "state": "inside" } }, "enterprise_contact": { "email": "contact@kmem.app", "subject": "Enterprise plan — org_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "body": "Tell us about your team: how many seats, and which organization this is for." }, "upgrade": { "checkout_available": false, "provider": null, "grant_variable": "SIGNPOST_PAID_ORG_IDS" } } ``` --- ### `GET /api/notes` List a folder. `folder=` with `recursive=true` is the whole knowledge base, and there is no separate endpoint that says that — a function called `listAllNotes` is what a reconciler reaches for just before it deletes everything. `has_more` is true when a full page came back; it is not a total count, because counting every match to render a page is work nobody asked for. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `limit` | integer | no | `50` | Rows to return. | | `offset` | integer | no | `0` | Rows to skip. | | `folder` | string | no | `""` | Folder to list. `` is the top level. | | `recursive` | `"true"` \| `"false"` | no | `"false"` | `true` to include nested folders. With `folder=` that is the whole instance. | | `include_deleted` | `"true"` \| `"false"` | no | `"false"` | `true` to include tombstoned notes, so they can be restored. | | `permalinks` | string | no | — | Space-separated permalinks. Lists exactly these docs, where they exist and this session may read them, in no particular order; `folder`, `recursive` and `order` are ignored, and `include_deleted=true` answers a tombstone at one of these addresses too, so a client can tell a deleted doc from one that never existed. A permalink cannot contain whitespace, so the list is unambiguous. At most 50. What a client's own list of recently opened docs asks, so it shows a doc only while the server still answers for it, under its current title. | | `order` | `"permalink"` \| `"created"` | no | `"permalink"` | `permalink`, the default, lists by address: the order a folder is read in. `created` lists the newest-created doc first, which is what a list of new docs needs. Sorting one address-ordered page by date in the client shows the docs that sort first in the alphabet, not the newest. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `folder` | string | yes | — | | | `recursive` | boolean | yes | — | | | `notes` | object[] | yes | — | | | `notes[].permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `notes[].ref` | string | yes | — | `kmem://permalink`. | | `notes[].title` | string | yes | — | | | `notes[].folder` | string | yes | — | `` for the top level. | | `notes[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `notes[].content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `notes[].created_at` | string | yes | — | ISO-8601 UTC. | | `notes[].updated_at` | string | yes | — | ISO-8601 UTC. | | `notes[].deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `notes[].freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `notes[].half_life_days` | number | yes | — | | | `notes[].visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `notes[].restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `notes[].provenance` | object \| null | yes | — | Who wrote this doc and whether anybody has checked it: the same object `GET /api/tree` carries for the same row, built by the same code, so the two lists cannot disagree. Metadata about the doc, never a word of its text. `null` for a tombstoned row, which has no live provenance. | | `page` | object | yes | — | | | `page.limit` | integer | yes | — | | | `page.offset` | integer | yes | — | | | `page.returned` | integer | yes | — | Rows in this response. | | `page.has_more` | boolean | yes | — | True when a full page came back, so there may be another. Not a total count. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/notes?folder=backend&limit=50 ``` ```json { "folder": "backend", "recursive": false, "notes": [ { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false, "provenance": { "author_kind": "human", "written_by": "ankur", "uploaded_by": null, "accepted_by": null, "verified_by": null, "verified_at": null, "status": null, "mixed_authorship": false, "pending_proposals": 0, "agent_name": null, "agent_owner": null } } ], "page": { "limit": 50, "offset": 0, "returned": 1, "has_more": false } } ``` --- ### `POST /api/notes` Create a note. Refused when a live note already has that address; a soft-deleted address may be reclaimed. The Markdown is stored verbatim — parse, render and reparse are byte-identical (invariant I5), so what is read back is what was sent. **Success:** `201` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `markdown` | string | yes | — | Complete Markdown including frontmatter. Stored verbatim. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `content_sha` | string | yes | — | | | `blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `committed_at` | string | yes | — | ISO-8601 UTC. | | `blocks` | object | yes | — | | | `blocks.created` | string[] | yes | — | | | `blocks.updated` | string[] | yes | — | | | `blocks.removed` | string[] | yes | — | | | `relations` | object | yes | — | | | `relations.resolved` | object[] | yes | — | | | `relations.resolved[].rel_type` | string | yes | — | | | `relations.resolved[].to` | string | yes | — | | | `relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `relations.unresolved[].rel_type` | string | yes | — | | | `relations.unresolved[].to` | string | yes | — | | | `audit_id` | string | yes | — | | | `index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `permalink-taken` | application | `409` | A live note already has that address. Commit it as an update, or choose another. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/notes ``` ```json { "permalink": "backend/auth-decision", "markdown": "---\ntype: decision\n---\n\n# Auth decision\n\nSession cookies over JWT — chosen for revocation simplicity.\n", "rationale": "Recording the outcome of the 14 March review." } ``` ```json { "outcome": "created", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } ``` --- ### `POST /api/notes/restore` Bring a soft-deleted note back. Reads the note's own last version out of `blobs/` and commits it, so the restored note is byte-identical to what was deleted and its blocks, chunks, full-text entries, inbound edges and canonical file all come back together. Takes the address in the body rather than the path because it is a command, and because the body carries the reason alongside it. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `content_sha` | string | yes | — | | | `blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `committed_at` | string | yes | — | ISO-8601 UTC. | | `blocks` | object | yes | — | | | `blocks.created` | string[] | yes | — | | | `blocks.updated` | string[] | yes | — | | | `blocks.removed` | string[] | yes | — | | | `relations` | object | yes | — | | | `relations.resolved` | object[] | yes | — | | | `relations.resolved[].rel_type` | string | yes | — | | | `relations.resolved[].to` | string | yes | — | | | `relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `relations.unresolved[].rel_type` | string | yes | — | | | `relations.unresolved[].to` | string | yes | — | | | `audit_id` | string | yes | — | | | `index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `note-not-deleted` | application | `409` | The note is live, so there is nothing to restore. | | `version-missing` | application | `404` | The version it was deleted at is not in `blobs/`. Restore a different one from the history. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/notes/restore ``` ```json { "permalink": "backend/auth-decision" } ``` ```json { "outcome": "updated", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } ``` --- ### `POST /api/notes/move` Change a note's address. A rename and a move are the same operation. A note has no id in its Markdown, so its address is its identity and changing it changes what every `kmem://` ref and every `[[wikilink]]` points at. Three steps in one order that invariant I2 fixes: create at `to`, repoint every inbound wikilink this caller can read, then delete `from` with a reason naming the new address. A create refuses an occupied address, so a move onto one stops with both notes untouched, and nothing is ever deleted before the new address holds the bytes. Links are rewritten by splice, never by a global replace, so `from` inside a code fence or inside a longer permalink is left alone. A note this caller may not read that links here is not repointed — that would disclose it (invariant I7) — and is counted in `links.unchanged` instead. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | string | yes | — | The address the note lives at now. | | `to` | string | yes | — | The address it should live at. A rename and a move are this. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | string | yes | — | | | `to` | string | yes | — | | | `note` | object | yes | — | The note at its new address. | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `links` | object | yes | — | | | `links.rewritten` | object[] | yes | — | Every note whose `[[from]]` now says `[[to]]`, each its own commit. | | `links.rewritten[].from` | string | yes | — | The note that carried the link. `kmem://permalink`. | | `links.rewritten[].occurrences` | integer | yes | — | How many `[[targets]]` in that note were rewritten. | | `links.unchanged` | integer | yes | — | Inbound edges that carried no matching wikilink to rewrite — a `kmem://` ref in prose, or a differently-cased target. They still point at the old address. | | `audit_id` | string | yes | — | The `note_moved` audit row. Each repointed link has its own. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `note-not-moved` | application | `409` | `from` and `to` are the same address, so the move would change nothing. | | `permalink-taken` | application | `409` | A live note already has the `to` address. Nothing was written, and the note is still at `from`. | | `content-missing` | application | `404` | The note has no canonical file behind it, so there are no bytes to move. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/notes/move ``` ```json { "from": "backend/auth-decision", "to": "decisions/session-cookies", "rationale": "Decisions live under decisions/ now." } ``` ```json { "from": "backend/auth-decision", "to": "decisions/session-cookies", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "links": { "rewritten": [ { "from": "kmem://product/roadmap", "occurrences": 1 } ], "unchanged": 0 }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5" } ``` --- ### `GET /api/notes/{permalink}` One note: its Markdown, and every block with its waymark. Everything the editor needs on first paint — the source to edit, the block ids to address edits to, and a status record per block for the reader's status chips. The note itself carries counts rather than a status record of its own: a note is not a claim (design/DESIGN.md, provenance and status). **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `permalink` | The note address, with its slashes: `backend/auth-decision`. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `frontmatter` | object | yes | — | The parsed YAML header. | | `markdown` | string | yes | — | The doc's current Markdown, byte for byte: the canonical file, or the same version from the content-addressed history when that file is missing. Never an empty stand-in — when neither copy exists the read is refused with `content-missing`. | | `blocks` | object[] | yes | — | | | `blocks[].id` | string | yes | — | A block id, `blk_` followed by eight hex characters. | | `blocks[].ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx`. Resolvable with `read`. | | `blocks[].ordinal` | integer | yes | — | Zero-based position in document order. | | `blocks[].kind` | string | yes | — | heading \| para \| list_item \| code \| observation \| table \| … | | `blocks[].text` | string | yes | — | Normalised plain text: what is hashed, chunked and searched. | | `blocks[].heading_path` | string[] | yes | — | Enclosing headings, outermost first. | | `blocks[].tags` | string[] | yes | — | `#tag` tokens, without the hash. | | `blocks[].observation_category` | string \| null | yes | — | The bracketed label of `- [category] content`, when this is an observation. | | `blocks[].waymark` | object | yes | — | Provenance and freshness for one block: who wrote it, through which door, whether anybody has verified it, and how much its age has discounted it. | | `blocks[].waymark.author_kind` | `"human"` \| `"agent"` | yes | — | | | `blocks[].waymark.author` | string \| null | yes | — | The account id, or an agent identity, exactly as `provenance` recorded it. Null for a person's write served to a reader who is not a signed-in member, such as a published note. | | `blocks[].waymark.author_name` | string \| null | yes | — | The person `author` names, by `personDisplayName`. Null for an agent's write, when nobody was recorded, when no account on this instance matches, and for a reader who is not a signed-in member. Show `author` only when it is not an account id. | | `blocks[].waymark.agent_client` | string \| null | yes | — | `claude-code`, `cursor`. Null for human writes. | | `blocks[].waymark.agent_model` | string \| null | yes | — | | | `blocks[].waymark.on_behalf_of_name` | string \| null | yes | — | The person who owns the agent's connection, by name, so a client can print "claude-code for Priya". Null for a human write, when no account matches the recorded owner, and for a reader who is not a signed-in member. Never an account id. | | `blocks[].waymark.session_id` | string \| null | yes | — | Groups every write from one sitting. Null for a reader who is not a signed-in member. | | `blocks[].waymark.source` | `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` | yes | — | | | `blocks[].waymark.created_at` | string | yes | — | ISO-8601 UTC. | | `blocks[].waymark.state` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `blocks[].waymark.verified_by` | string \| null | yes | — | The account id of whoever last set `state`. A verifier only while `state` is `verified`: on a stale or superseded claim it names whoever marked it so, which may be an agent's label. Null for a reader who is not a signed-in member. | | `blocks[].waymark.verified_by_name` | string \| null | yes | — | The person `verified_by` names. Null on the same three counts as `author_name`. | | `blocks[].waymark.verified_at` | string \| null | yes | — | ISO-8601 UTC. | | `blocks[].waymark.accepted_by` | string \| null | yes | — | The account id of the person who accepted these words through a review decision, or null. The third trust rung: a person let the words in and did not attest to each line — a different act from `verified_by`, recorded beside it. Null for a reader who is not a signed-in member. | | `blocks[].waymark.accepted_by_name` | string \| null | yes | — | The person `accepted_by` names. Null on the same three counts as `author_name`. | | `blocks[].waymark.accepted_at` | string \| null | yes | — | ISO-8601 UTC of the acceptance. Both acceptance fields are set, or neither. | | `blocks[].waymark.freshness` | number | yes | — | `exp(-age_days / half_life)`, in (0, 1]. The opacity of the waymark's bar. | | `blocks[].waymark.half_life_days` | number | yes | — | The half-life this note type decays over (PRD 4.2). | | `blocks[].waymark.past_half_life` | boolean | yes | — | True once the claim is older than its half-life. | | `blocks[].waymark.description` | string | yes | — | The glyph in words, for the accessible name. Never the sole carrier of meaning. | | `provenance` | object | yes | — | Counts per author kind and trust rung. A note is not a claim, so it carries counts rather than a status record of its own (design/DESIGN.md, provenance and status). | | `provenance.blocks` | integer | yes | — | | | `provenance.human` | integer | yes | — | | | `provenance.agent` | integer | yes | — | | | `provenance.verified` | integer | yes | — | | | `provenance.accepted` | integer | yes | — | Claims a person accepted through review and nobody has ruled on since. Counted apart from `unverified`, so "n unverified" cannot claim nobody looked at words a person let in. | | `provenance.unverified` | integer | yes | — | | | `provenance.stale` | integer | yes | — | | | `provenance.superseded` | integer | yes | — | | | `provenance.unattributed` | integer | yes | — | Blocks with no provenance row at all. | | `doc_provenance` | object | yes | — | Who wrote this doc and whether anybody has checked it — the same object `GET /api/tree`, `/api/recall` and the review queue carry. Beside `provenance` rather than replacing it: that field is the per-block census, a different question with the same name, and the doc reader's properties panel reads it today. The two will merge in a later release; until then this is the one to draw a status chip and an author line from. | | `doc_provenance.author_kind` | `"human"` \| `"agent"` | yes | — | Who composed the current version: a person, or an assistant. | | `doc_provenance.written_by` | string \| null | yes | — | The person who wrote the words, by name. For an upload this is the Markdown's own `author` front matter and never the importer, so a zip of somebody else's documentation is not attributed to whoever uploaded it. `null` when nothing recorded one, which a client renders as "Author not recorded". | | `doc_provenance.uploaded_by` | string \| null | yes | — | The person who ran the import, by name. `null` for a doc that was not imported. | | `doc_provenance.accepted_by` | string \| null | yes | — | The person who let an assistant's words in through review, by name. Always `null` on a human-authored doc, which has no proposal to accept. | | `doc_provenance.verified_by` | string \| null | yes | — | The person who checked the current version, by name. | | `doc_provenance.verified_at` | string \| null | yes | — | ISO-8601 UTC of the check `verified_by` names, or `null`. Beside the name rather than inside it, because the doc screen prints "Verified by {name} · {date}" and a browse row prints the name alone. | | `doc_provenance.status` | `"superseded"` \| `"stale"` \| `"unverified"` \| `"accepted"` \| `"verified"` \| null | yes | — | The chip, or `null` for none. `null` is the answer for a doc a person wrote or uploaded that nobody has verified, staled or superseded: `unverified` and `accepted` belong to the assistant proposal path, and a person who writes a doc has proposed nothing. Never a value outside verified, accepted, unverified, stale, superseded. | | `doc_provenance.mixed_authorship` | boolean | yes | — | True when the doc's claims carry more than one author kind or more than one author. `author_kind` and `written_by` describe the write the doc came from, which is the whole answer for most docs and a brief one for a doc a person wrote and an assistant later added a paragraph to. A client with room says so; a client without one reads `written_by` and is not wrong. | | `doc_provenance.pending_proposals` | integer | yes | — | Open proposals that target this doc, counted over this reader's own read scope. `0` when nothing is waiting. | | `doc_provenance.agent_name` | string \| null | yes | — | The agent that wrote the doc, by the name its connection carries. `null` for a doc a person wrote. | | `doc_provenance.agent_owner` | string \| null | yes | — | The person who owns that agent's connection, by name, so a client can print "Codex for Ankur". `null` when no account matches the recorded owner, and for a reader who is not a signed-in member. Never an account id. | | `verification` | `"on"` \| `"off"` | yes | — | Whether this doc's org uses verification (`docs/ADR/0018`). `off` means every fact is equal: draw no Unverified, Accepted or Verified mark on any block, whatever its `waymark.state` says. `doc_provenance` already follows the mode, and so does each `waymark.description`. The other `waymark` fields do not, because they state what is stored. A deployment with no org reads `on`. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | **Example** ```http GET /api/notes/backend/auth-decision ``` ```json { "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "frontmatter": { "type": "decision" }, "markdown": "---\ntype: decision\n---\n\n# Auth decision\n\nSession cookies over JWT — chosen for revocation simplicity.\n", "blocks": [ { "id": "blk_7f3a1b2c", "ref": "kmem://backend/auth-decision#blk_7f3a1b2c", "ordinal": 2, "kind": "para", "text": "Session cookies over JWT — chosen for revocation simplicity.", "heading_path": [ "Auth decision" ], "tags": [], "observation_category": null, "waymark": { "author_kind": "human", "author": "usr_7c1e4b2a9d3f", "author_name": "ankur", "agent_client": null, "agent_model": null, "on_behalf_of_name": null, "session_id": "sess_9f21", "source": "web", "created_at": "2026-03-14T09:22:00.000Z", "state": "verified", "verified_by": "usr_7c1e4b2a9d3f", "verified_by_name": "ankur", "verified_at": "2026-03-20T11:04:00.000Z", "accepted_by": null, "accepted_by_name": null, "accepted_at": null, "freshness": 0.91, "half_life_days": 180, "past_half_life": false, "description": "Written by ankur. Verified by ankur." } } ], "provenance": { "blocks": 1, "human": 1, "agent": 0, "verified": 1, "accepted": 0, "unverified": 0, "stale": 0, "superseded": 0, "unattributed": 0 }, "doc_provenance": { "author_kind": "human", "written_by": "ankur", "uploaded_by": null, "accepted_by": null, "verified_by": "ankur", "verified_at": "2026-03-20T11:04:00.000Z", "status": "verified", "mixed_authorship": false, "pending_proposals": 0, "agent_name": null, "agent_owner": null }, "verification": "on" } ``` --- ### `PATCH /api/notes/{permalink}` Replace a note's contents. Send `expected_sha` — the `content_sha` you read — and a concurrent edit is refused rather than overwritten. Omitting it is last-writer-wins, which is right for a `git pull` and wrong for a person. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `permalink` | The note address. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `markdown` | string | yes | — | Complete replacement Markdown including frontmatter. | | `expected_sha` | string | no | — | The `content_sha` the caller believes is current. When it is stale the write is refused rather than overwriting somebody else's edit. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `content_sha` | string | yes | — | | | `blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `committed_at` | string | yes | — | ISO-8601 UTC. | | `blocks` | object | yes | — | | | `blocks.created` | string[] | yes | — | | | `blocks.updated` | string[] | yes | — | | | `blocks.removed` | string[] | yes | — | | | `relations` | object | yes | — | | | `relations.resolved` | object[] | yes | — | | | `relations.resolved[].rel_type` | string | yes | — | | | `relations.resolved[].to` | string | yes | — | | | `relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `relations.unresolved[].rel_type` | string | yes | — | | | `relations.unresolved[].to` | string | yes | — | | | `audit_id` | string | yes | — | | | `index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http PATCH /api/notes/backend/auth-decision ``` ```json { "markdown": "---\ntype: decision\n---\n\n# Auth decision\n\nSession cookies over JWT — chosen for revocation simplicity.\n\n- supersedes [[backend/jwt-spike]]\n", "expected_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a" } ``` ```json { "outcome": "updated", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } ``` --- ### `DELETE /api/notes/{permalink}` Soft-delete a note, with a reason. The row is tombstoned, never removed, and every version stays in `blobs/`. The reason is required and is recorded in the audit log: unexplained deletions are how trust in a knowledge base dies. Restore it with `POST /api/notes/restore`. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `permalink` | The note address. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `reason` | string | yes | — | Required. Unexplained deletions are how trust in a knowledge base dies. | | `expected_sha` | string | no | — | The `content_sha` the caller believes is current. When it is stale the write is refused rather than overwriting somebody else's edit. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `content_sha` | string | yes | — | | | `blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `committed_at` | string | yes | — | ISO-8601 UTC. | | `blocks` | object | yes | — | | | `blocks.created` | string[] | yes | — | | | `blocks.updated` | string[] | yes | — | | | `blocks.removed` | string[] | yes | — | | | `relations` | object | yes | — | | | `relations.resolved` | object[] | yes | — | | | `relations.resolved[].rel_type` | string | yes | — | | | `relations.resolved[].to` | string | yes | — | | | `relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `relations.unresolved[].rel_type` | string | yes | — | | | `relations.unresolved[].to` | string | yes | — | | | `audit_id` | string | yes | — | | | `index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `delete-refused` | application | `409` | The deletion was not authorised — the note changed between reading it and deleting it. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http DELETE /api/notes/backend/auth-decision ``` ```json { "reason": "Superseded by the platform auth RFC." } ``` ```json { "outcome": "deleted", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } ``` --- ### `POST /api/folders/move` Rename or move a folder, and every doc under it. A folder is the front of the addresses under it and nothing else, so this is one `POST /api/notes/move` per doc, through the same code: each doc keeps its id, and with it its history, provenance, chunks and vectors, and every inbound wikilink this caller can read is rewritten. The folder's policy files move too, last, so no doc loses its level. Every doc is checked before the first move — the reads and guards the move commit itself runs, so a person may only move what they may write — and one refusal refuses the whole request with `folder-refused`, naming the doc and the rule. Past that, docs move one at a time and a failure stops the run: `moved`, `failed` and `untouched` say where every doc is. One request moves at most 20 docs: a larger folder moves its notes a part at a time and its policy files in the last part, and `remaining` says how many are left, so send the same request again until it is 0. Refused onto a doc or a folder this caller can see, unless every doc there was moved from this folder by an earlier part, and into the folder's own subtree. A path whose only live files are policy files is not taken. Docs this caller cannot read are neither moved, counted nor named (invariant I7). **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | string | yes | — | The folder as it is now. | | `to` | string | yes | — | The whole new path. A rename and a move are one operation: `handbook` to `people/handbook` moves it, `handbook` to `guide` renames it. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | string | yes | — | | | `to` | string | yes | — | | | `moved` | object[] | yes | — | Every doc that moved, old address to new, in the order they moved. Each kept its id, so its history, provenance, chunks and vectors went with it, and every inbound link the caller can read was rewritten. A policy file that limits who can open its folder is the exception: a copy of it is written at the new address before the first doc moves, and the original is deleted in the part that reaches it, so that entry is a new doc. | | `moved[].from` | string | yes | — | | | `moved[].to` | string | yes | — | | | `failed` | object \| null | yes | — | The one doc the run stopped at, or `null` after a full success. Every doc was checked before the first move, so this is a write that landed in between or a store that failed. | | `untouched` | string[] | yes | — | Docs the run never reached, still at their old addresses. | | `remaining` | integer | yes | — | Docs still under `from` that this request did not reach, because one request moves at most 20. When it is above 0 and `failed` is `null`, send the same request again to move the next part. | | `links_rewritten` | integer | yes | — | Inbound wikilinks rewritten across the whole batch, each its own commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-folder` | protocol | `400` | A path is not one an address could start with. | | `folder-not-found` | application | `404` | No doc this caller can read lives under `from` — the same answer for an empty folder and a withheld one. | | `folder-not-moved` | application | `409` | `from` and `to` are the same path. | | `folder-into-itself` | application | `409` | `to` is inside `from`. Nothing was moved. | | `folder-taken` | application | `409` | A doc this caller can see lives at `to`, or `to` is already a folder with docs this move did not put there. Nothing was moved. | | `folder-refused` | application | `409` | One doc of the folder cannot be moved — a doc or a tombstone at its new address, a permission, a missing file — so none was. The message names the doc and the rule. | **Example** ```http POST /api/folders/move ``` ```json { "from": "backend", "to": "platform/backend", "rationale": "Backend docs live under platform/ now." } ``` ```json { "from": "backend", "to": "platform/backend", "moved": [ { "from": "backend/auth-decision", "to": "platform/backend/auth-decision" }, { "from": "backend/session-store", "to": "platform/backend/session-store" } ], "failed": null, "untouched": [], "remaining": 0, "links_rewritten": 3 } ``` --- ### `POST /api/folders/delete` Delete every doc in a folder, as one batch the Undo restores. One tombstone per doc through the single write path (invariant I2): each needs the reason, each keeps every version in `blobs/`, and each carries one shared `batch_id` in the audit log, which `POST /api/folders/restore` takes back. Owner-only. Only the docs this caller can read are deleted and counted, so `deleted` is exactly what the tree showed them; policy files stay, and `policy_files_kept` names them, because their level applies to whatever is created under the folder later. Every doc is checked before the first tombstone and one refusal refuses the whole request. One bound, refused before anything is written: 50 docs per request. The fraction a reconciliation may remove does not apply: this is a person's reasoned act, one batch the Undo restores whole. No agent door offers this: an assistant deletes one doc at a time. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `folder` | string | yes | — | A folder path — `handbook` or `handbook/onboarding` — with no leading slash. A folder is the front of the addresses under it; a leading or trailing slash is ignored. | | `reason` | string | yes | — | Required, as it is for one doc, and recorded on every tombstone. Unexplained deletions are how trust in a knowledge base dies. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `folder` | string | yes | — | | | `deleted` | integer | yes | — | How many docs were tombstoned: exactly the docs this session could see in the folder, unless `failed` is set. Policy files stay; they are not docs. | | `policy_files_kept` | string[] | yes | — | The folder's policy files, which a delete leaves live. Their level still applies to any doc created or moved under the folder later, so the web app names them. | | `batch_id` | string | yes | — | The one id every tombstone of this delete carries in the audit log, and what `POST /api/folders/restore` takes. | | `failed` | object \| null | yes | — | The one doc the run stopped at, or `null`. Everything before it is deleted and comes back with `batch_id`. | | `untouched` | string[] | yes | — | Docs the run never reached. Still live. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-folder` | protocol | `400` | The path is not one an address could start with. | | `folder-not-found` | application | `404` | No doc this caller can read lives under the folder — the same answer for an empty folder and a withheld one. | | `folder-too-large` | application | `409` | The folder holds more than 50 docs. Nothing was deleted; delete its subfolders as separate actions, or its docs one at a time. | | `folder-delete-owner-only` | application | `403` | The caller is not an owner. Nothing was deleted. | | `folder-refused` | application | `409` | One doc of the folder cannot be deleted — a permission, a changed version, a missing file — so none was. The message names the doc and the rule. | **Example** ```http POST /api/folders/delete ``` ```json { "folder": "drafts/2025", "reason": "Superseded by the 2026 plans. Every version stays in history." } ``` ```json { "folder": "drafts/2025", "deleted": 18, "batch_id": "fdel_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "failed": null, "untouched": [], "policy_files_kept": [] } ``` --- ### `POST /api/folders/restore` Undo one folder delete, by its batch id. Restores every doc the batch tombstoned that this caller can read, each from the bytes it was deleted at, at the address it had — the same restore `POST /api/notes/restore` makes for one doc. A doc that is live again, or was deleted again by a later act, is skipped and said so, which makes asking twice safe: the second answer restores nothing and skips everything. A doc whose restore is refused is reported with its reason and the rest still go through. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `batch_id` | string | yes | — | The `batch_id` `POST /api/folders/delete` answered with. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `batch_id` | string | yes | — | | | `restored` | integer | yes | — | Docs this request brought back, byte for byte, at the addresses they were deleted from. | | `permalinks` | string[] | yes | — | Their addresses, in the order they were restored. | | `skipped` | object[] | yes | — | Docs of the batch this request left alone. Asking twice is safe: the second answer skips them all. | | `skipped[].permalink` | string | yes | — | | | `skipped[].reason` | `"already-live"` \| `"deleted-again"` | yes | — | `already-live`: restored already, or written again since, so there is nothing to undo. `deleted-again`: deleted again by a later act, which is the one to undo. | | `failed` | object[] | yes | — | Docs whose restore was refused, each with its cause and fix. The rest still went through. | | `failed[].permalink` | string | yes | — | | | `failed[].error` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `batch-not-found` | application | `404` | No deletion this caller can see carries that batch id. | **Example** ```http POST /api/folders/restore ``` ```json { "batch_id": "fdel_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11" } ``` ```json { "batch_id": "fdel_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "restored": 2, "permalinks": [ "drafts/2025/q1-plan", "drafts/2025/q2-plan" ], "skipped": [], "failed": [] } ``` --- ### `GET /api/share` Who can open one doc or one folder. The two levels the Share dialog offers, read from the item's own header (ADR 0026): `open` (every member can read it, unless a folder above is private) and `private` (only owners and the member addresses and `group:` addresses in `entries`; `access:` or `visibility: private` sets it, and an empty list means owners only). A connection reads what its person reads, so there is no agents setting. `inherited` names the private folders above, and the level they set. A folder with no `_folder.md` answers 200 with `exists: false`, and so does a folder the caller may not open, exactly as a folder that holds nothing (ADR 0020). A doc the caller may not open answers 404 with the words a missing doc gets (invariant I7). `path: ""` is the top level, which has no list: `can_share` is false and `reason` says why. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `kind` | `"doc"` \| `"folder"` | yes | — | `doc` for one doc, `folder` for a folder's `_folder.md`. | | `path` | string | yes | — | The doc's address, or the folder's path. `` is the top level. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `kind` | `"doc"` \| `"folder"` | yes | — | `doc` for one doc, `folder` for a folder's `_folder.md`. | | `path` | string | yes | — | | | `name` | string | yes | — | The doc's title, or the folder's last segment. | | `exists` | boolean | yes | — | False for a folder with no `_folder.md` yet. A doc always exists here. | | `content_sha` | string \| null | yes | — | | | `own` | object | yes | — | | | `own.level` | `"open"` \| `"private"` | yes | — | The item's own level. `private` when it carries `access:` or `visibility: private`. | | `own.entries` | string[] | yes | — | | | `own.visibility` | `"open"` \| `"private"` \| null | yes | — | The item's own `visibility:`, or null. `escorted` and `sealed` read as `open` (ADR 0026). | | `own.invalid` | object \| null | yes | — | The stored list does not parse, so only owners can open the item. | | `inherited` | object | yes | — | | | `inherited.list_folders` | string[] | yes | — | Private folders above the item, outermost first. | | `inherited.lists` | object[] | yes | — | | | `inherited.lists[].folder` | string | yes | — | | | `inherited.lists[].entries` | string[] | yes | — | | | `inherited.visibility` | `"open"` \| `"private"` | yes | — | The level the item has with no level of its own. | | `inherited.visibility_from` | string \| null | yes | — | The folder that sets `visibility`, or null for the instance default. | | `viewer` | object | yes | — | | | `viewer.user_id` | string | yes | — | | | `viewer.email` | string \| null | yes | — | | | `viewer.is_owner` | boolean | yes | — | | | `viewer.can_share` | boolean | yes | — | | | `viewer.reason` | string \| null | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | **Example** ```http GET /api/share?kind=doc&path=finance/q3-plan ``` ```json { "kind": "doc", "path": "finance/q3-plan", "name": "Q3 plan", "exists": true, "content_sha": "3f2a9c1e5b7d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d0f1a3c5e7b9d2f4a", "own": { "level": "private", "entries": [ "priya@acme.com", "group:finance" ], "visibility": "private", "invalid": null }, "inherited": { "list_folders": [], "lists": [], "visibility": "open", "visibility_from": null }, "viewer": { "user_id": "usr_priya", "email": "priya@acme.com", "is_owner": false, "can_share": true, "reason": null } } ``` --- ### `POST /api/share` Change who can open one doc or one folder. Reads the item's current Markdown, changes only `access:` and `visibility:` in its header, and commits it through the single write path: every other key, every comment and the body stay byte for byte. `private` writes the list in one canonical order, and, when the item was open, `visibility: private` and the person who saves; `open` removes the list and the item's own `visibility: private` (ADR 0026). A folder with no `_folder.md` gets one. Anybody who can open the item may change it (ADR 0023), with two refusals: nobody removes their own access (`write-refused`), and an agent never changes access. Agents read what the person who connected them can read, so there is no agents choice. The same choice twice answers `unchanged` and writes nothing. Emits `share_changed` at warn with counts, never addresses. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `kind` | `"doc"` \| `"folder"` | yes | — | `doc` for one doc, `folder` for a folder's `_folder.md`. | | `path` | string | yes | — | | | `level` | string | yes | — | `open` removes the item's own list and its `visibility: private`. `private` writes `access:` with `entries`; when the item was open, it also writes `visibility: private` and adds the person who saves to the list. An empty list means owners only. | | `entries` | string[] | no | `[]` | Member email addresses and group addresses (`group:finance`). Read only when `level` is `private`. | | `expected_sha` | string \| null | yes | — | The `content_sha` the GET answered, or null when it answered `exists: false`. A different value now is a 409. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"unchanged"` | yes | — | | | `share` | object | yes | — | | | `share.kind` | `"doc"` \| `"folder"` | yes | — | `doc` for one doc, `folder` for a folder's `_folder.md`. | | `share.path` | string | yes | — | | | `share.name` | string | yes | — | The doc's title, or the folder's last segment. | | `share.exists` | boolean | yes | — | False for a folder with no `_folder.md` yet. A doc always exists here. | | `share.content_sha` | string \| null | yes | — | | | `share.own` | object | yes | — | | | `share.own.level` | `"open"` \| `"private"` | yes | — | The item's own level. `private` when it carries `access:` or `visibility: private`. | | `share.own.entries` | string[] | yes | — | | | `share.own.visibility` | `"open"` \| `"private"` \| null | yes | — | The item's own `visibility:`, or null. `escorted` and `sealed` read as `open` (ADR 0026). | | `share.own.invalid` | object \| null | yes | — | The stored list does not parse, so only owners can open the item. | | `share.inherited` | object | yes | — | | | `share.inherited.list_folders` | string[] | yes | — | Private folders above the item, outermost first. | | `share.inherited.lists` | object[] | yes | — | | | `share.inherited.lists[].folder` | string | yes | — | | | `share.inherited.lists[].entries` | string[] | yes | — | | | `share.inherited.visibility` | `"open"` \| `"private"` | yes | — | The level the item has with no level of its own. | | `share.inherited.visibility_from` | string \| null | yes | — | The folder that sets `visibility`, or null for the instance default. | | `share.viewer` | object | yes | — | | | `share.viewer.user_id` | string | yes | — | | | `share.viewer.email` | string \| null | yes | — | | | `share.viewer.is_owner` | boolean | yes | — | | | `share.viewer.can_share` | boolean | yes | — | | | `share.viewer.reason` | string \| null | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `share-level-unknown` | protocol | `400` | `level` is not `inherit`, `people` or `owners`. | | `access-invalid` | protocol | `400` | An entry is not a member address or a group address, or there are more than 200. | | `share-kind-mismatch` | protocol | `400` | `kind` is `doc` and `path` names a folder's `_folder.md`. | | `share-root` | protocol | `403` | `path` is the top level, which has no list. | | `share-changed` | application | `409` | `expected_sha` is not the item's current `content_sha`. Nothing was written. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/share ``` ```json { "kind": "doc", "path": "finance/q3-plan", "level": "private", "entries": [ "priya@acme.com", "group:finance" ], "expected_sha": "3f2a9c1e5b7d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d0f1a3c5e7b9d2f4a" } ``` ```json { "outcome": "updated", "share": { "kind": "doc", "path": "finance/q3-plan", "name": "Q3 plan", "exists": true, "content_sha": "3f2a9c1e5b7d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d0f1a3c5e7b9d2f4a", "own": { "level": "private", "entries": [ "priya@acme.com", "group:finance" ], "visibility": "private", "invalid": null }, "inherited": { "list_folders": [], "lists": [], "visibility": "open", "visibility_from": null }, "viewer": { "user_id": "usr_priya", "email": "priya@acme.com", "is_owner": false, "can_share": true, "reason": null } } } ``` --- ### `GET /api/groups` The org's people and its groups. For any member, because the Share dialog offers the org's people and groups to anybody who can share. Each group carries its fixed address (`handle`) and the entry an `access:` list writes to name it, and the ids of its members, who are all current org members; the member list is shown to an owner who is not in the group, which better-auth's own `list-team-members` refuses. People are sorted by name, 1,000 at most. A self-hosted instance has no org: it answers its accounts and no groups. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `people` | object[] | yes | — | | | `people[].user_id` | string | yes | — | | | `people[].name` | string | yes | — | The account's name, or the part of its address before the @. | | `people[].email` | string | yes | — | | | `people[].role` | `"owner"` \| `"admin"` \| `"member"` | yes | — | | | `groups` | object[] | yes | — | | | `groups[].id` | string | yes | — | | | `groups[].name` | string | yes | — | | | `groups[].handle` | string \| null | yes | — | The fixed address. A rename does not change it. Null for a group with none. | | `groups[].entry` | string \| null | yes | — | What an `access:` list writes to name this group: `group:`. | | `groups[].member_ids` | string[] | yes | — | The account ids of the members, who are all org members. | | `viewer` | object | yes | — | | | `viewer.user_id` | string | yes | — | | | `viewer.manages_groups` | boolean | yes | — | True for an owner or an admin of this org. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/groups ``` ```json { "people": [ { "user_id": "usr_owner", "name": "Ankur", "email": "ankur@acme.com", "role": "owner" }, { "user_id": "usr_priya", "name": "Priya", "email": "priya@acme.com", "role": "member" } ], "groups": [ { "id": "team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Finance", "handle": "finance", "entry": "group:finance", "member_ids": [ "usr_priya" ] } ], "viewer": { "user_id": "usr_owner", "manages_groups": true } } ``` --- ### `POST /api/groups` Create a group. Its address is fixed from its name. The address is the slug of the name (`Finance Team` gives `finance-team`); a taken address gets `-2` to `-99`, and a name the slug rule empties gets `g-` and the group's id. The team and its address are written in one batch. Owners and admins only. Emits `group_created` at warn. **Success:** `201` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | string | yes | — | 1 to 80 characters. Unique in the org, ignoring case. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `group` | object | yes | — | | | `group.id` | string | yes | — | | | `group.name` | string | yes | — | | | `group.handle` | string \| null | yes | — | The fixed address. A rename does not change it. Null for a group with none. | | `group.entry` | string \| null | yes | — | What an `access:` list writes to name this group: `group:`. | | `group.member_ids` | string[] | yes | — | The account ids of the members, who are all org members. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `group-manage-refused` | protocol | `403` | The caller is not an owner or an admin of this org. | | `groups-hosted-only` | application | `404` | This is a self-hosted instance, which has no org and no groups. | | `group-name-missing` | protocol | `400` | The name is empty. | | `group-name-too-long` | protocol | `400` | The name has more than 80 characters. | | `group-name-taken` | application | `409` | Another group in this org has the same name, ignoring case, width and inner spaces. | **Example** ```http POST /api/groups ``` ```json { "name": "Finance" } ``` ```json { "group": { "id": "team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Finance", "handle": "finance", "entry": "group:finance", "member_ids": [] } } ``` --- ### `PATCH /api/groups/{id}` Rename a group. Its address stays. Changes the name and never the address, so every `access:` list that names the group keeps working. Owners and admins only. Emits `group_renamed` at warn with both names. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The group id from `GET /api/groups`. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | string | yes | — | 1 to 80 characters. Unique in the org, ignoring case. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `group` | object | yes | — | | | `group.id` | string | yes | — | | | `group.name` | string | yes | — | | | `group.handle` | string \| null | yes | — | The fixed address. A rename does not change it. Null for a group with none. | | `group.entry` | string \| null | yes | — | What an `access:` list writes to name this group: `group:`. | | `group.member_ids` | string[] | yes | — | The account ids of the members, who are all org members. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `group-manage-refused` | protocol | `403` | The caller is not an owner or an admin of this org. | | `groups-hosted-only` | application | `404` | This is a self-hosted instance, which has no org and no groups. | | `group-name-missing` | protocol | `400` | The name is empty. | | `group-name-too-long` | protocol | `400` | The name has more than 80 characters. | | `group-name-taken` | application | `409` | Another group in this org has the same name, ignoring case, width and inner spaces. | | `group-not-found` | application | `404` | No group with this id exists in this org. | **Example** ```http PATCH /api/groups/team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11 ``` ```json { "name": "Finance and payroll" } ``` ```json { "group": { "id": "team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Finance and payroll", "handle": "finance", "entry": "group:finance", "member_ids": [ "usr_priya" ] } } ``` --- ### `DELETE /api/groups/{id}` Delete a group and its memberships. Its members lose the access the group gave them. Lists keep the entry, which then names nobody; a group created later with the same address gets that access, which the delete dialog says. A pending invitation that named the group keeps the invitation and loses the group. Answers the group as it was. Owners and admins only. Emits `group_deleted` at warn. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The group id from `GET /api/groups`. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `group` | object | yes | — | | | `group.id` | string | yes | — | | | `group.name` | string | yes | — | | | `group.handle` | string \| null | yes | — | The fixed address. A rename does not change it. Null for a group with none. | | `group.entry` | string \| null | yes | — | What an `access:` list writes to name this group: `group:`. | | `group.member_ids` | string[] | yes | — | The account ids of the members, who are all org members. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `group-manage-refused` | protocol | `403` | The caller is not an owner or an admin of this org. | | `groups-hosted-only` | application | `404` | This is a self-hosted instance, which has no org and no groups. | | `group-not-found` | application | `404` | No group with this id exists in this org. | **Example** ```http DELETE /api/groups/team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11 ``` ```json { "group": { "id": "team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Finance", "handle": "finance", "entry": "group:finance", "member_ids": [ "usr_priya" ] } } ``` --- ### `POST /api/groups/{id}/members` Add org members to a group. 1 to 50 account ids per request; a person already in the group is skipped. Every id must be a member of this org. Their access changes on their next request. Owners and admins only. Emits `group_member_added` at warn per person added. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The group id from `GET /api/groups`. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `user_ids` | string[] | yes | — | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `group` | object | yes | — | | | `group.id` | string | yes | — | | | `group.name` | string | yes | — | | | `group.handle` | string \| null | yes | — | The fixed address. A rename does not change it. Null for a group with none. | | `group.entry` | string \| null | yes | — | What an `access:` list writes to name this group: `group:`. | | `group.member_ids` | string[] | yes | — | The account ids of the members, who are all org members. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `group-manage-refused` | protocol | `403` | The caller is not an owner or an admin of this org. | | `groups-hosted-only` | application | `404` | This is a self-hosted instance, which has no org and no groups. | | `group-not-found` | application | `404` | No group with this id exists in this org. | | `group-members-limit` | protocol | `400` | The request names no one, or more than 50 people. | | `group-member-unknown` | protocol | `400` | An id is not a member of this org. Nobody was added. | **Example** ```http POST /api/groups/team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/members ``` ```json { "user_ids": [ "usr_priya" ] } ``` ```json { "group": { "id": "team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Finance", "handle": "finance", "entry": "group:finance", "member_ids": [ "usr_priya" ] } } ``` --- ### `DELETE /api/groups/{id}/members/{userId}` Take one person out of a group. The person loses the access the group gave them on their next request; access they have in other ways stays. Owners and admins only. Emits `group_member_removed` at warn. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The group id from `GET /api/groups`. | | `userId` | The person's account id. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `group` | object | yes | — | | | `group.id` | string | yes | — | | | `group.name` | string | yes | — | | | `group.handle` | string \| null | yes | — | The fixed address. A rename does not change it. Null for a group with none. | | `group.entry` | string \| null | yes | — | What an `access:` list writes to name this group: `group:`. | | `group.member_ids` | string[] | yes | — | The account ids of the members, who are all org members. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `group-manage-refused` | protocol | `403` | The caller is not an owner or an admin of this org. | | `groups-hosted-only` | application | `404` | This is a self-hosted instance, which has no org and no groups. | | `group-not-found` | application | `404` | No group with this id exists in this org. | **Example** ```http DELETE /api/groups/team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11/members/usr_priya ``` ```json { "group": { "id": "team_4e1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "name": "Finance", "handle": "finance", "entry": "group:finance", "member_ids": [] } } ``` --- ### `GET /api/blocks/{block_id}` One block and its waymark. Block ids are derived from content and heading path, never written into the Markdown, and reconciled across reparses — so editing block 3 leaves the ids of 1, 2, 4 and 5 alone. An id that no longer resolves means its own content changed; read the note again and take a current one. A block id is unique only within its doc: docs made from one template hold the same ids. So send `note`, the permalink half of the block's ref. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `block_id` | `blk_` and eight hex characters. | **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `note` | string | no | — | The permalink of the doc the block belongs to, the part of `kmem://{note}#{block_id}` before `#`. Send it: without it, an id that two readable docs hold is refused with `block-ambiguous`. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `block` | object | yes | — | | | `block.id` | string | yes | — | A block id, `blk_` followed by eight hex characters. | | `block.ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx`. Resolvable with `read`. | | `block.ordinal` | integer | yes | — | Zero-based position in document order. | | `block.kind` | string | yes | — | heading \| para \| list_item \| code \| observation \| table \| … | | `block.text` | string | yes | — | Normalised plain text: what is hashed, chunked and searched. | | `block.heading_path` | string[] | yes | — | Enclosing headings, outermost first. | | `block.tags` | string[] | yes | — | `#tag` tokens, without the hash. | | `block.observation_category` | string \| null | yes | — | The bracketed label of `- [category] content`, when this is an observation. | | `block.waymark` | object | yes | — | Provenance and freshness for one block: who wrote it, through which door, whether anybody has verified it, and how much its age has discounted it. | | `block.waymark.author_kind` | `"human"` \| `"agent"` | yes | — | | | `block.waymark.author` | string \| null | yes | — | The account id, or an agent identity, exactly as `provenance` recorded it. Null for a person's write served to a reader who is not a signed-in member, such as a published note. | | `block.waymark.author_name` | string \| null | yes | — | The person `author` names, by `personDisplayName`. Null for an agent's write, when nobody was recorded, when no account on this instance matches, and for a reader who is not a signed-in member. Show `author` only when it is not an account id. | | `block.waymark.agent_client` | string \| null | yes | — | `claude-code`, `cursor`. Null for human writes. | | `block.waymark.agent_model` | string \| null | yes | — | | | `block.waymark.on_behalf_of_name` | string \| null | yes | — | The person who owns the agent's connection, by name, so a client can print "claude-code for Priya". Null for a human write, when no account matches the recorded owner, and for a reader who is not a signed-in member. Never an account id. | | `block.waymark.session_id` | string \| null | yes | — | Groups every write from one sitting. Null for a reader who is not a signed-in member. | | `block.waymark.source` | `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` | yes | — | | | `block.waymark.created_at` | string | yes | — | ISO-8601 UTC. | | `block.waymark.state` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `block.waymark.verified_by` | string \| null | yes | — | The account id of whoever last set `state`. A verifier only while `state` is `verified`: on a stale or superseded claim it names whoever marked it so, which may be an agent's label. Null for a reader who is not a signed-in member. | | `block.waymark.verified_by_name` | string \| null | yes | — | The person `verified_by` names. Null on the same three counts as `author_name`. | | `block.waymark.verified_at` | string \| null | yes | — | ISO-8601 UTC. | | `block.waymark.accepted_by` | string \| null | yes | — | The account id of the person who accepted these words through a review decision, or null. The third trust rung: a person let the words in and did not attest to each line — a different act from `verified_by`, recorded beside it. Null for a reader who is not a signed-in member. | | `block.waymark.accepted_by_name` | string \| null | yes | — | The person `accepted_by` names. Null on the same three counts as `author_name`. | | `block.waymark.accepted_at` | string \| null | yes | — | ISO-8601 UTC of the acceptance. Both acceptance fields are set, or neither. | | `block.waymark.freshness` | number | yes | — | `exp(-age_days / half_life)`, in (0, 1]. The opacity of the waymark's bar. | | `block.waymark.half_life_days` | number | yes | — | The half-life this note type decays over (PRD 4.2). | | `block.waymark.past_half_life` | boolean | yes | — | True once the claim is older than its half-life. | | `block.waymark.description` | string | yes | — | The glyph in words, for the accessible name. Never the sole carrier of meaning. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `block-not-found` | application | `404` | No block exists at that address. | | `note-not-found` | application | `404` | No note exists at that address. | | `block-ambiguous` | application | `409` | No `note` was sent, and more than one doc you can read holds a block with this id. Send `note`. | **Example** ```http GET /api/blocks/blk_7f3a1b2c?note=backend/auth-decision ``` ```json { "block": { "id": "blk_7f3a1b2c", "ref": "kmem://backend/auth-decision#blk_7f3a1b2c", "ordinal": 2, "kind": "para", "text": "Session cookies over JWT — chosen for revocation simplicity.", "heading_path": [ "Auth decision" ], "tags": [], "observation_category": null, "waymark": { "author_kind": "human", "author": "usr_7c1e4b2a9d3f", "author_name": "ankur", "agent_client": null, "agent_model": null, "on_behalf_of_name": null, "session_id": "sess_9f21", "source": "web", "created_at": "2026-03-14T09:22:00.000Z", "state": "verified", "verified_by": "usr_7c1e4b2a9d3f", "verified_by_name": "ankur", "verified_at": "2026-03-20T11:04:00.000Z", "accepted_by": null, "accepted_by_name": null, "accepted_at": null, "freshness": 0.91, "half_life_days": 180, "past_half_life": false, "description": "Written by ankur. Verified by ankur." } }, "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false } } ``` --- ### `POST /api/blocks/edit` `append` · `prepend` · `replace_block` · `set_property`. The four ops PRD 5.1 gives the MCP `edit` tool, on one endpoint, so the web app, the CLI and an assistant all drive the same operation. `replace_block` needs a block ref and replaces that block's source range, leaving every other byte and every other block id untouched. The other three take a note ref, are computed as pure functions over the parsed note, and are committed as an update pinned to the version they were computed from. **Success:** `200` **Request body** *When `op` is "append":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `op` | `"append"` | yes | — | Add `content` at the end of the note as its own block. | | `ref` | string | yes | — | `kmem://permalink`. | | `content` | string | yes | — | Raw Markdown to add. | | `expected_sha` | string | no | — | The `content_sha` the caller believes is current. When it is stale the write is refused rather than overwriting somebody else's edit. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | *When `op` is "prepend":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `op` | `"prepend"` | yes | — | Add `content` at the top of the body, below any frontmatter. | | `ref` | string | yes | — | `kmem://permalink`. | | `content` | string | yes | — | Raw Markdown to add. | | `expected_sha` | string | no | — | The `content_sha` the caller believes is current. When it is stale the write is refused rather than overwriting somebody else's edit. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | *When `op` is "replace_block":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `op` | `"replace_block"` | yes | — | Replace one block's source range. Every other block keeps its id. | | `ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx`. The block id is required. | | `content` | string | yes | — | Raw Markdown to put in its place. An empty string deletes the block. | | `expected_sha` | string | no | — | The `content_sha` the caller believes is current. When it is stale the write is refused rather than overwriting somebody else's edit. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | *When `op` is "set_property":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `op` | `"set_property"` | yes | — | Set or remove one frontmatter key. | | `ref` | string | yes | — | `kmem://permalink`. | | `key` | string | yes | — | The frontmatter key. | | `value` | string \| number \| boolean \| null \| string \| number \| boolean \| null[] \| object | no | — | Omit the field entirely to remove the key. `null` writes a null value. | | `expected_sha` | string | no | — | The `content_sha` the caller believes is current. When it is stale the write is refused rather than overwriting somebody else's edit. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `content_sha` | string | yes | — | | | `blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `committed_at` | string | yes | — | ISO-8601 UTC. | | `blocks` | object | yes | — | | | `blocks.created` | string[] | yes | — | | | `blocks.updated` | string[] | yes | — | | | `blocks.removed` | string[] | yes | — | | | `relations` | object | yes | — | | | `relations.resolved` | object[] | yes | — | | | `relations.resolved[].rel_type` | string | yes | — | | | `relations.resolved[].to` | string | yes | — | | | `relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `relations.unresolved[].rel_type` | string | yes | — | | | `relations.unresolved[].to` | string | yes | — | | | `audit_id` | string | yes | — | | | `index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-ref` | protocol | `400` | The `ref` is not a `kmem://` address, or names the wrong kind of thing for this op. | | `note-not-found` | application | `404` | No note exists at that address. | | `block-not-found` | application | `404` | The note has no block with that id. Reparse and take a current one. | | `content-missing` | application | `404` | The canonical file is absent although D1 names a version. Restore it from the history. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/blocks/edit ``` ```json { "op": "append", "ref": "kmem://backend/auth-decision", "content": "- supersedes [[backend/jwt-spike]] (decided 14 March)" } ``` ```json { "outcome": "updated", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } ``` --- ### `POST /api/media` Store one image against a note and describe it. The file is base64 in the body, at most 10 MiB decoded. Its type is sniffed from the bytes — a claimed content type is never read, and SVG is refused because it can carry a script this origin would then serve as its own. A vision model writes the alt text; when it fails or exceeds its deadline the alt text comes from the filename, and `alt_source` says which. **This endpoint changes no note.** It returns `![alt](url)` for you to insert with `POST /api/blocks/edit`, so the insertion gets its own blob, provenance row and index job like any other edit. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `permalink` | string | yes | — | The note this image belongs to. The caller must be able to read and write it. | | `filename` | string | yes | — | The name of the file. Sanitised, and its extension is replaced by the sniffed one. | | `content_base64` | string | yes | — | The file, base64-encoded. At most 10 MiB once decoded. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `alt` | string | yes | — | The description, one line, as a person reads it. Not escaped: `markdown` is the escaped form. Empty when nothing could describe the image. | | `alt_source` | `"vision"` \| `"filename"` \| `"none"` | yes | — | `vision` when the model wrote it, `filename` when the model failed and the file name carried something, `none` when neither did. | | `alt_failure` | string \| null | yes | — | One sentence about a description that did not happen, or null. | | `alt_ms` | integer | yes | — | Wall time of the vision call, in milliseconds. | | `neurons` | number \| null | yes | — | Neurons Cloudflare billed, as the response reported them. Null when it did not. | | `usd` | number \| null | yes | — | `neurons` in US dollars. Null on the same terms — nothing here estimates. | | `asset_id` | string | yes | — | `ast_` and sixteen hex characters. | | `url` | string | yes | — | `/api/media/{assetId}/{filename}`. A path on this instance, served to a signed-in reader who may read the owning note. An export or a git mirror carries the path, not the image, and a published page does not serve it to an anonymous reader. | | `filename` | string | yes | — | The name it is stored under, which may differ from the one sent. | | `content_type` | `"image/png"` \| `"image/jpeg"` \| `"image/gif"` \| `"image/webp"` \| `"image/avif"` | yes | — | Sniffed from the bytes, never claimed. | | `bytes` | integer | yes | — | | | `content_sha` | string | yes | — | SHA-256 of the bytes, lowercase hex. | | `deduplicated` | boolean | yes | — | True when this note already held these exact bytes. | | `markdown` | string | yes | — | `![alt](url)`. Insert it with POST /api/blocks/edit; this endpoint changes no note. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `file-too-large` | protocol | `413` | The decoded file is over 10 MiB. Resize or re-compress it. | | `unsupported-media-type` | protocol | `415` | The bytes are not PNG, JPEG, GIF, WebP or AVIF. Renaming a file does not change what it is. | **Example** ```http POST /api/media ``` ```json { "permalink": "backend/auth-decision", "filename": "auth-flow.png", "content_base64": "iVBORw0KGgoAAAANSUhEUg==" } ``` ```json { "asset_id": "ast_7f3a1c2d9e4b6a08", "url": "/api/media/ast_7f3a1c2d9e4b6a08/auth-flow.png", "filename": "auth-flow.png", "content_type": "image/png", "bytes": 118312, "content_sha": "9f2c4d7e1a8b3f6002d5e8c1b4a7092f3e6d9c2b5a8f1e4d7c0b3a6952e8d1f4", "deduplicated": false, "markdown": "![Sequence diagram of the refresh token exchange between the client and the gateway](/api/media/ast_7f3a1c2d9e4b6a08/auth-flow.png)", "alt": "Sequence diagram of the refresh token exchange between the client and the gateway", "alt_source": "vision", "alt_failure": null, "alt_ms": 2413, "neurons": 62.456417, "usd": 0.000687 } ``` --- ### `GET /api/media/{asset_id}/{filename}` The bytes of one image. An image is readable exactly when the note it belongs to is readable — one rule, the ladder's own, applied by resolving the owning note through the caller's scope before a byte moves. Every refusal is the same 404: no such object, no such note, a note this scope cannot read, and an object belonging to a different note are one answer, because distinguishing them would confirm the existence of a note the ladder is hiding (invariant I7). A Quick Connect link is not accepted here; a link holder receives the alt text inside the note's Markdown and never the pixels. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `asset_id` | `ast_` and sixteen hex characters. | | `filename` | The name the upload response returned, not the one you sent. | **Response** The image itself, with the sniffed content type and `X-Content-Type-Options: nosniff`. _No fields._ **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `asset-not-found` | application | `404` | No asset exists at that address. | **Example** ```http GET /api/media/ast_7f3a1c2d9e4b6a08/auth-flow.png ``` ```json "" ``` --- ### `POST /api/media/{asset_id}/describe` Ask the vision model again for an image already stored. The retry the upload deliberately does not do. An upload bounds its wait on a person's attention rather than on the model, so a slow call comes back with a filename-derived placeholder; this asks again without re-sending the bytes. A `POST` because it spends money, scoped by the same note rule as the `GET`, so nobody can buy a description of a note they may not read. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `asset_id` | `ast_` and sixteen hex characters. | **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `filename` | string | yes | — | The stored filename, as the upload returned it. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `alt` | string | yes | — | The description, one line, as a person reads it. Not escaped: `markdown` is the escaped form. Empty when nothing could describe the image. | | `alt_source` | `"vision"` \| `"filename"` \| `"none"` | yes | — | `vision` when the model wrote it, `filename` when the model failed and the file name carried something, `none` when neither did. | | `alt_failure` | string \| null | yes | — | One sentence about a description that did not happen, or null. | | `alt_ms` | integer | yes | — | Wall time of the vision call, in milliseconds. | | `neurons` | number \| null | yes | — | Neurons Cloudflare billed, as the response reported them. Null when it did not. | | `usd` | number \| null | yes | — | `neurons` in US dollars. Null on the same terms — nothing here estimates. | | `asset_id` | string | yes | — | | | `markdown` | string | yes | — | `![alt](url)` with the new description, for a splice. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `asset-not-found` | application | `404` | No asset exists at that address. | **Example** ```http POST /api/media/ast_7f3a1c2d9e4b6a08/describe?filename=auth-flow.png ``` ```json { "asset_id": "ast_7f3a1c2d9e4b6a08", "markdown": "![Sequence diagram of the refresh token exchange between the client and the gateway](/api/media/ast_7f3a1c2d9e4b6a08/auth-flow.png)", "alt": "Sequence diagram of the refresh token exchange between the client and the gateway", "alt_source": "vision", "alt_failure": null, "alt_ms": 1878, "neurons": 62.488, "usd": 0.000687 } ``` --- ### `GET /api/backlinks/{permalink}` Every edge pointing at a note. Unresolved edges are included, and they are the interesting ones: a relation may name a note that does not exist yet — an agent writing B before A is the ordinary case — and invariant I3 says such an edge is stored and never dropped. So an address with no note at all can still have inbound edges, and this endpoint answers for it. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `permalink` | The note address, whether or not a note is there. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `permalink` | string | yes | — | | | `ref` | string | yes | — | `kmem://permalink`. | | `note` | object \| null | yes | — | The note at this address, or null. A permalink with no note can still have inbound edges. | | `inbound` | object[] | yes | — | Every edge pointing here, resolved or not. | | `inbound[].rel_type` | string | yes | — | The verb, snake_case: `depends_on`, `supersedes`, `relates_to`. | | `inbound[].context` | string \| null | yes | — | The trailing parenthesised aside, without its brackets. | | `inbound[].resolved` | boolean | yes | — | True once the edge has been matched to a live note. False is normal, not an error. | | `inbound[].from` | object \| null | yes | — | The note the edge left, or null when that note has since been deleted. | | `counts` | object | yes | — | | | `counts.total` | integer | yes | — | | | `counts.resolved` | integer | yes | — | | | `counts.unresolved` | integer | yes | — | Edges awaiting this address. Non-zero on a note that does not exist yet. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/backlinks/backend/session-store ``` ```json { "permalink": "backend/session-store", "ref": "kmem://backend/session-store", "note": null, "inbound": [ { "rel_type": "depends_on", "context": "decided 14 March", "resolved": false, "from": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "type": "decision" } } ], "counts": { "total": 1, "resolved": 0, "unresolved": 1 } } ``` --- ### `GET /api/history/{permalink}` Every version, and a diff between any two. History is the content-addressed store, not a table: every commit writes the complete Markdown to `blobs/{sha256}` before anything in D1 changes, so the bucket remains a complete, restorable history with nothing else running. Pass `from` (and optionally `to`, which defaults to the current version) to get the diff alongside the list. Tombstoned notes are included — seeing a deleted note is the point of a history screen. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `permalink` | The note address. Deleted notes are included. | **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | string | no | — | A version sha. With `to`, the response also carries the diff between them. | | `to` | string | no | — | A version sha. Defaults to the current version when only `from` is given. | | `include_content` | `"true"` \| `"false"` | no | `"false"` | `true` also returns the Markdown of `from` and `to`. Off by default: versions are whole notes. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `versions` | object[] | yes | — | Oldest first, which is how a history reads. | | `versions[].n` | integer | yes | — | Position in the history, oldest first, starting at 1. Counts distinct contents rather than commits: identical bytes are one version. Never use it as a concurrency token; `expected_sha` is that. | | `versions[].sha` | string | yes | — | SHA-256 of this version's bytes, and its address in `blobs/`. | | `versions[].key` | string | yes | — | `blobs/{sha}`. | | `versions[].size` | integer | yes | — | Bytes. | | `versions[].committed_at` | string \| null | yes | — | ISO-8601 UTC, from the committer. Null for a version written before this was recorded. | | `versions[].uploaded_at` | string | yes | — | ISO-8601 UTC, from R2. | | `versions[].source` | string \| null | yes | — | Which door wrote it: web \| mcp \| quickconnect \| git \| ingest. | | `versions[].author` | string \| null | yes | — | Who wrote these bytes: a person by name, an assistant by the name its connection carries. Null for a version stored before this was recorded, and for a person no account on this instance names; never an account id. It names the first committer of this content, not whoever last made it current: restoring an old version writes no new object, so this keeps naming the person who typed the words. GET /api/audit answers who made it current. | | `versions[].author_kind` | `"human"` \| `"agent"` \| null | yes | — | Whether a person or an assistant wrote it. Null when the author is unrecorded. | | `versions[].on_behalf_of_name` | string \| null | yes | — | For an assistant's version, the person who owns its connection, by name. Null for a person's version, for a version stored before 2026-09-19, and when no account matches. Never an account id. | | `versions[].is_current` | boolean | yes | — | True for the version `notes.content_sha` names right now. | | `diff` | object \| null | yes | — | Present only when `from` was given. | | `content` | object \| null | yes | — | The Markdown of the two compared versions. Present only with `include_content=true`. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `version-missing` | application | `404` | One of `from` or `to` is not in `blobs/`, so there is nothing to compare. | **Example** ```http GET /api/history/backend/auth-decision?from=0b1c2d3e4f5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0 ``` ```json { "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "versions": [ { "n": 1, "sha": "0b1c2d3e4f5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", "key": "blobs/0b1c2d3e4f5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", "size": 118, "committed_at": "2026-03-14T09:22:00.000Z", "uploaded_at": "2026-03-14T09:22:00.000Z", "source": "web", "author": "Priya Sharma", "author_kind": "human", "on_behalf_of_name": null, "is_current": false }, { "n": 2, "sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "size": 142, "committed_at": "2026-03-20T11:04:00.000Z", "uploaded_at": "2026-03-20T11:04:00.000Z", "source": "web", "author": "claude-code", "author_kind": "agent", "on_behalf_of_name": "Priya Sharma", "is_current": true } ], "diff": { "from": "0b1c2d3e4f5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", "to": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "added": 1, "removed": 0, "unchanged": 1, "truncated": false, "hunks": [ { "from_start": 6, "from_lines": 1, "to_start": 6, "to_lines": 2, "lines": [ { "kind": "context", "from_line": 6, "to_line": 6, "text": "Session cookies over JWT — chosen for revocation simplicity." }, { "kind": "added", "from_line": null, "to_line": 7, "text": "- supersedes [[backend/jwt-spike]]" } ] } ] }, "content": null } ``` --- ### `POST /api/history/restore` Make an old version current again. An ordinary update whose Markdown comes out of the history, so the restored version becomes the newest version rather than replacing anything: restoring is itself undoable and `blobs/` never loses an entry. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `sha` | string | yes | — | The version to restore, from the version list. | | `expected_sha` | string | no | — | The version the caller believes is current, so a concurrent edit is refused. | | `rationale` | string | no | — | Free text explaining why, recorded in the audit log. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `note` | object | yes | — | | | `note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `note.ref` | string | yes | — | `kmem://permalink`. | | `note.title` | string | yes | — | | | `note.folder` | string | yes | — | `` for the top level. | | `note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `note.created_at` | string | yes | — | ISO-8601 UTC. | | `note.updated_at` | string | yes | — | ISO-8601 UTC. | | `note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `note.half_life_days` | number | yes | — | | | `note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `content_sha` | string | yes | — | | | `blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `committed_at` | string | yes | — | ISO-8601 UTC. | | `blocks` | object | yes | — | | | `blocks.created` | string[] | yes | — | | | `blocks.updated` | string[] | yes | — | | | `blocks.removed` | string[] | yes | — | | | `relations` | object | yes | — | | | `relations.resolved` | object[] | yes | — | | | `relations.resolved[].rel_type` | string | yes | — | | | `relations.resolved[].to` | string | yes | — | | | `relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `relations.unresolved[].rel_type` | string | yes | — | | | `relations.unresolved[].to` | string | yes | — | | | `audit_id` | string | yes | — | | | `index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `version-missing` | application | `404` | No object exists at `blobs/{sha}`. Use a sha from the version list. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/history/restore ``` ```json { "permalink": "backend/auth-decision", "sha": "0b1c2d3e4f5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", "rationale": "The 20 March edit dropped the rationale." } ``` ```json { "outcome": "updated", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } ``` --- ### `GET /api/proposals` The review queue. What fills this queue is the instance's write policy. On `conflicts`, the shipped default, a new doc from an agent lands in Docs and a change to an existing doc waits here for a person — the act worth a person's attention is the one that can destroy work (`docs/ADR/0017`). `review` queues every agent write instead, `open` queues none, and `locked` refuses them. A person's own write is never queued by policy. `write_policy` on this response says which of the four is in force. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `status` | `"pending"` \| `"accepted"` \| `"rejected"` \| `"withdrawn"` \| `"decided"` | no | — | Only proposals in this state. `rejected` is the changes a person rejected, and `withdrawn` is the changes the assistant that queued them took back itself, as `counts` splits them. `decided` is accepted, rejected and withdrawn together, which is what the review screen's Decided tab lists. Omit for every state. | | `batch` | string | no | — | Only the proposals of this submission, in the order they were submitted: oldest first, where every other read of the queue is newest first. The batch detail screen sends it so that opening one batch does not load every other batch's proposed text. Omit for every batch. | | `session` | string | no | — | Only the proposals of this sitting that belong to no recorded batch: the rows written before submissions carried an id, which a client groups as a session group. Without it a session group could only be read as the newest page of the whole queue, and an older group's rows fell off it. | | `limit` | integer | no | `50` | | | `offset` | integer | no | `0` | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `proposals` | object[] | yes | — | | | `proposals[].id` | string | yes | — | | | `proposals[].kind` | `"create"` \| `"update"` \| `"delete"` | yes | — | What it asks to do. | | `proposals[].target_permalink` | string \| null | yes | — | The note it would create, change or remove. | | `proposals[].target_ref` | string \| null | yes | — | `kmem://permalink`, when it names one. | | `proposals[].origin` | string | yes | — | Which door proposed it: mcp \| quickconnect \| ingest \| git. | | `proposals[].session_id` | string \| null | yes | — | Groups a whole agent session for review as a unit. | | `proposals[].batch_id` | string \| null | yes | — | The submission this proposal arrived in, set once at creation and never changed. `null` on a row written before the column existed; a client groups those by `session_id` and labels the group a session group rather than implying they were submitted together. | | `proposals[].on_behalf_of` | string \| null | yes | — | The named person accountable for it: the `created_by` of the link it arrived through (ADR 0009), or the person who uploaded the file for an upload that would replace a doc. A reviewer needs to know on whose authority a queued write was made, not only what it says. | | `proposals[].on_behalf_of_name` | string \| null | yes | — | The person `on_behalf_of` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `proposals[].agent_client` | string \| null | yes | — | What the assistant called itself in this sitting: claude-code, cursor, chatgpt. Resolved from `session_id` against `provenance` first and the worklog second, and null when neither recorded one — a door that never said is a fact, not a name to invent. | | `proposals[].rationale` | string \| null | yes | — | Why the agent thinks this is worth keeping. A historic upload's `Upload batch: imp_…` line is not part of it: that line is the row's grouping key, and it is sent as `batch_id`. | | `proposals[].status` | `"pending"` \| `"accepted"` \| `"rejected"` | yes | — | | | `proposals[].created_at` | string | yes | — | ISO-8601 UTC. | | `proposals[].decided_at` | string \| null | yes | — | ISO-8601 UTC. | | `proposals[].decided_by` | string \| null | yes | — | | | `proposals[].decided_by_name` | string \| null | yes | — | The person `decided_by` names. For a withdrawal, the name of the connection that took the change back, or the name the assistant gave itself. Null when nothing names it. | | `proposals[].payload` | object | yes | — | The proposed change, as it will be handed to CommitService. | | `proposals[].payload_error` | string \| null | yes | — | Set when the stored payload is not a decodable change. Such a proposal can be rejected but not accepted. | | `proposals[].base_sha` | string \| null | yes | — | The target's `content_sha` the proposal was written against, from the payload's own `expected_sha`. `null` when the door recorded none, which is a fact about the door rather than a claim that nothing moved. | | `proposals[].live_sha` | string \| null | yes | — | The target's `content_sha` as it reads now. `null` when no live doc is there. | | `proposals[].stale_base` | boolean | yes | — | True when the proposal is pending and `base_sha` and `live_sha` are both known and differ: somebody changed the doc after the proposal was made, the compare-and-swap in the commit would refuse the accept, and the reviewer has to load the latest version and judge it again. False whenever either sha is unknown, because `false` here means `not known to be stale` and a guess would be worse than an absence. Always false for an accepted or rejected proposal: a decided change has nothing left to compare, and after an accepted update the live sha is the proposal's own text. | | `proposals[].decision` | `"accepted"` \| `"rejected"` \| `"keep_both"` \| `"withdrawn"` \| null | yes | — | Which resolution a reviewer chose. `keep_both` is an acceptance whose words landed at a new address instead of over the current doc, so `status` reads `accepted` for both. `withdrawn` means that nobody decided: the assistant that queued the change took it back before a person did. `status` reads `rejected` for it, and a client shows it as withdrawn and never as a person's rejection. `null` while the proposal is pending. | | `proposals[].replaced_by` | string \| null | yes | — | For a withdrawn change, the id of a later change to the same address from the same batch or sitting: the assistant took this one back and sent that one instead. `null` for every other decision, and for a withdrawal that nothing replaced at that address. | | `proposals[].decision_permalink` | string \| null | yes | — | The doc a `keep_both` created. `null` for every other decision. | | `proposals[].keep_both_available` | boolean | yes | — | Whether a reviewer may keep both versions of this change. `false` for a deletion, which has no second version, and for a change recorded before submissions carried an id — that change has no batch address to post the decision to, so the control cannot work and the screen must not draw it. Posting `keep_both` on such an item answers 409 saying the same thing. | | `proposals[].provenance` | object \| null | yes | — | Who wrote the doc this change targets, and whether anybody has checked it — the same object `GET /api/tree` and `GET /api/notes/{permalink}` carry. `null` when nothing stands at the address yet, which is every `create`: there is no doc to have provenance. | | `batches` | object[] | yes | — | Every submission in the queue, oldest first, over the whole `status` set and not only this page. The queue screen lists these; the detail screen then asks for one batch's proposals with `?batch=`. | | `batches[].id` | string \| null | yes | — | The batch id. `null` for a group of rows written before the column existed, which a client labels a session group. | | `batches[].session_id` | string \| null | yes | — | The sitting every row in the batch agrees on, or null when they differ. | | `batches[].title` | string \| null | yes | — | The title the submitting door gave this batch. Always `null` today: no door submits one, so a client falls back to naming the agent rather than inventing a title. | | `batches[].origin` | string \| null | yes | — | The door every row agrees on: mcp \| quickconnect \| ingest \| git. Null when mixed. | | `batches[].agent_client` | string \| null | yes | — | What the assistant called itself, resolved from the sitting. Null when unrecorded. | | `batches[].connection_label` | string \| null | yes | — | The name a person gave the link the batch arrived through, such as "Audit agent". Null when the sitting was not a link, which is every upload. A client names the agent by it where the client recorded no name of its own, rather than saying "An assistant" over a connection its owner named. | | `batches[].connected_by` | string \| null | yes | — | The person accountable for the batch, by name: the owner of the link for an agent's batch, the uploader for an upload. Null when no account on this instance matches. | | `batches[].connected_by_id` | string \| null | yes | — | The account id `connected_by` names. | | `batches[].created_at` | string | yes | — | ISO-8601 UTC. The oldest row in the batch, so the queue can put the longest wait first. | | `batches[].first_rationale` | string \| null | yes | — | The reason the batch's first proposal gave, as `rationale` sends it. Never a join of several reasons: a sentence assembled out of two rationales is one no agent wrote. | | `batches[].first_target` | string \| null | yes | — | The address the batch's oldest change targets, or null when none names one. A client names an untitled batch with no agent by it, "Changes to handbook/review and 2 more", so six uploads in the queue do not read as six copies of "Proposed changes". | | `batches[].counts` | object | yes | — | `total` is the sum of `new`, `updated`, `deleted` and `withdrawn`. The three kinds leave out the withdrawn changes, so they count what the batch applies. | | `batches[].counts.total` | integer | yes | — | | | `batches[].counts.new` | integer | yes | — | | | `batches[].counts.updated` | integer | yes | — | | | `batches[].counts.deleted` | integer | yes | — | | | `batches[].counts.withdrawn` | integer | yes | — | How many of `total` the assistant took back itself before anybody decided. Always 0 under `status=pending`. | | `batches[].keep_both_available` | boolean | yes | — | Whether this submission can be decided with keep-both at all. `false` for a group of rows written before the column existed: it has no batch address, so `POST /api/proposals/batches/{id}/accept` cannot be reached for it and every decision on it is taken one row at a time. | | `counts` | object | yes | — | | | `counts.pending` | integer | yes | — | | | `counts.accepted` | integer | yes | — | | | `counts.rejected` | integer | yes | — | The changes a person rejected. A withdrawal is not one of them. | | `counts.withdrawn` | integer | yes | — | The changes the assistant that queued them took back before anybody decided. Stored as rejected, and never counted as a rejection. | | `page` | object | yes | — | | | `page.limit` | integer | yes | — | | | `page.offset` | integer | yes | — | | | `page.returned` | integer | yes | — | Rows in this response. | | `page.has_more` | boolean | yes | — | True when a full page came back, so there may be another. Not a total count. | | `write_policy` | `"open"` \| `"conflicts"` \| `"review"` \| `"locked"` | yes | — | How this instance treats an agent's write. `conflicts` is the shipped default and fills this queue with changes to docs that already exist, while a new doc from an agent lands in Docs. `review` queues every agent write; `open` commits every one directly; `locked` refuses them. | | `can_decide` | boolean | yes | — | Whether this session may accept or reject. `PersonRole` is owner or member today and both may, so this is true for every signed-in member; it exists so a client can draw the read-only queue from one field the day a viewer role lands, rather than from a role name it has to interpret. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/proposals?status=pending ``` ```json { "proposals": [], "batches": [], "counts": { "pending": 0, "accepted": 0, "rejected": 0, "withdrawn": 0 }, "page": { "limit": 50, "offset": 0, "returned": 0, "has_more": false }, "write_policy": "conflicts", "can_decide": true } ``` --- ### `GET /api/proposals/counts` How many proposals are in each state. The review queue's counts and nothing else: the numbers `GET /api/proposals` answers under `counts`, and how many batches are waiting, from reads that load no payload and name nobody. The navigation's Review pill polls this once a minute from every open tab and shows `pending_batches`. Ask `GET /api/proposals` when you need the proposals themselves. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `counts` | object | yes | — | The same scoped numbers `GET /api/proposals` returns under `counts`. A proposal whose target this session may not read is not counted, because a count is content. | | `counts.pending` | integer | yes | — | | | `counts.accepted` | integer | yes | — | | | `counts.rejected` | integer | yes | — | The changes a person rejected. A withdrawal is not one of them. | | `counts.withdrawn` | integer | yes | — | The changes the assistant that queued them took back before anybody decided. Stored as rejected, and never counted as a rejection. | | `pending_batches` | integer | yes | — | How many submissions are waiting: the length of the `batches` array `GET /api/proposals?status=pending` returns, grouped by the same rule and scoped the same way. The Review pill counts this, because the queue and every decision work per batch and `counts.pending` counts the changes inside them. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/proposals/counts ``` ```json { "counts": { "pending": 2, "accepted": 14, "rejected": 3, "withdrawn": 1 }, "pending_batches": 1 } ``` --- ### `POST /api/proposals/{id}/accept` Commit the proposed change, then stamp the row. In that order, because the failure modes are asymmetric: a proposal stamped `accepted` whose change never landed is a lie the review screen tells for ever, whereas a change that landed while the stamp failed leaves a pending row somebody can decide again — harmlessly, because the note already says what it asked for. The stored payload is re-validated on the way through; whatever it says about who is writing is discarded and replaced with whoever pressed accept. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The proposal id. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `rationale` | string | no | — | Why. Recorded in the audit log beside the decision. | | `based_on_sha` | string | no | — | The `content_sha` the reviewer compared a stale change against, on an accept. It must equal the live sha, and the commit then checks that sha rather than the one the change was proposed against — the same rule the batch endpoint applies. Ignored on a reject. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `proposal` | object | yes | — | | | `proposal.id` | string | yes | — | | | `proposal.kind` | `"create"` \| `"update"` \| `"delete"` | yes | — | What it asks to do. | | `proposal.target_permalink` | string \| null | yes | — | The note it would create, change or remove. | | `proposal.target_ref` | string \| null | yes | — | `kmem://permalink`, when it names one. | | `proposal.origin` | string | yes | — | Which door proposed it: mcp \| quickconnect \| ingest \| git. | | `proposal.session_id` | string \| null | yes | — | Groups a whole agent session for review as a unit. | | `proposal.batch_id` | string \| null | yes | — | The submission this proposal arrived in, set once at creation and never changed. `null` on a row written before the column existed; a client groups those by `session_id` and labels the group a session group rather than implying they were submitted together. | | `proposal.on_behalf_of` | string \| null | yes | — | The named person accountable for it: the `created_by` of the link it arrived through (ADR 0009), or the person who uploaded the file for an upload that would replace a doc. A reviewer needs to know on whose authority a queued write was made, not only what it says. | | `proposal.on_behalf_of_name` | string \| null | yes | — | The person `on_behalf_of` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `proposal.agent_client` | string \| null | yes | — | What the assistant called itself in this sitting: claude-code, cursor, chatgpt. Resolved from `session_id` against `provenance` first and the worklog second, and null when neither recorded one — a door that never said is a fact, not a name to invent. | | `proposal.rationale` | string \| null | yes | — | Why the agent thinks this is worth keeping. A historic upload's `Upload batch: imp_…` line is not part of it: that line is the row's grouping key, and it is sent as `batch_id`. | | `proposal.status` | `"pending"` \| `"accepted"` \| `"rejected"` | yes | — | | | `proposal.created_at` | string | yes | — | ISO-8601 UTC. | | `proposal.decided_at` | string \| null | yes | — | ISO-8601 UTC. | | `proposal.decided_by` | string \| null | yes | — | | | `proposal.decided_by_name` | string \| null | yes | — | The person `decided_by` names. For a withdrawal, the name of the connection that took the change back, or the name the assistant gave itself. Null when nothing names it. | | `proposal.payload` | object | yes | — | The proposed change, as it will be handed to CommitService. | | `proposal.payload_error` | string \| null | yes | — | Set when the stored payload is not a decodable change. Such a proposal can be rejected but not accepted. | | `proposal.base_sha` | string \| null | yes | — | The target's `content_sha` the proposal was written against, from the payload's own `expected_sha`. `null` when the door recorded none, which is a fact about the door rather than a claim that nothing moved. | | `proposal.live_sha` | string \| null | yes | — | The target's `content_sha` as it reads now. `null` when no live doc is there. | | `proposal.stale_base` | boolean | yes | — | True when the proposal is pending and `base_sha` and `live_sha` are both known and differ: somebody changed the doc after the proposal was made, the compare-and-swap in the commit would refuse the accept, and the reviewer has to load the latest version and judge it again. False whenever either sha is unknown, because `false` here means `not known to be stale` and a guess would be worse than an absence. Always false for an accepted or rejected proposal: a decided change has nothing left to compare, and after an accepted update the live sha is the proposal's own text. | | `proposal.decision` | `"accepted"` \| `"rejected"` \| `"keep_both"` \| `"withdrawn"` \| null | yes | — | Which resolution a reviewer chose. `keep_both` is an acceptance whose words landed at a new address instead of over the current doc, so `status` reads `accepted` for both. `withdrawn` means that nobody decided: the assistant that queued the change took it back before a person did. `status` reads `rejected` for it, and a client shows it as withdrawn and never as a person's rejection. `null` while the proposal is pending. | | `proposal.replaced_by` | string \| null | yes | — | For a withdrawn change, the id of a later change to the same address from the same batch or sitting: the assistant took this one back and sent that one instead. `null` for every other decision, and for a withdrawal that nothing replaced at that address. | | `proposal.decision_permalink` | string \| null | yes | — | The doc a `keep_both` created. `null` for every other decision. | | `proposal.keep_both_available` | boolean | yes | — | Whether a reviewer may keep both versions of this change. `false` for a deletion, which has no second version, and for a change recorded before submissions carried an id — that change has no batch address to post the decision to, so the control cannot work and the screen must not draw it. Posting `keep_both` on such an item answers 409 saying the same thing. | | `proposal.provenance` | object \| null | yes | — | Who wrote the doc this change targets, and whether anybody has checked it — the same object `GET /api/tree` and `GET /api/notes/{permalink}` carry. `null` when nothing stands at the address yet, which is every `create`: there is no doc to have provenance. | | `commit` | object | yes | — | What committing the proposed change actually did. | | `commit.outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | `unchanged` is a success: the content already matched, so nothing was written. | | `commit.note` | object | yes | — | | | `commit.note.permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `commit.note.ref` | string | yes | — | `kmem://permalink`. | | `commit.note.title` | string | yes | — | | | `commit.note.folder` | string | yes | — | `` for the top level. | | `commit.note.type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `commit.note.content_sha` | string | yes | — | SHA-256 of the canonical Markdown. Pass it back as `expected_sha`. | | `commit.note.created_at` | string | yes | — | ISO-8601 UTC. | | `commit.note.updated_at` | string | yes | — | ISO-8601 UTC. | | `commit.note.deleted_at` | string \| null | yes | — | ISO-8601 UTC when soft-deleted, else null. | | `commit.note.freshness` | number | yes | — | The note's own decay from `updated_at` over its type's half-life (PRD 4.2). | | `commit.note.half_life_days` | number | yes | — | | | `commit.note.visibility` | `"open"` \| `"private"` | yes | — | The **resolved** level (ADR 0009): the strictest of the instance default, the folder's `_folder.md` and this note's own `visibility:` frontmatter. Seeing it at all means the reader is inside it — the ladder filters this row before it is projected. | | `commit.note.restricted` | boolean | yes | — | True when this doc's own frontmatter limits who can open it (`access:`, ADR 0023). A doc inside a restricted folder is not marked by this field: the folder carries its own `restricted`. Only somebody who can open the doc receives it. | | `commit.content_sha` | string | yes | — | | | `commit.blob_key` | string | yes | — | `blobs/{content_sha}` — this exact version, kept for ever. | | `commit.committed_at` | string | yes | — | ISO-8601 UTC. | | `commit.blocks` | object | yes | — | | | `commit.blocks.created` | string[] | yes | — | | | `commit.blocks.updated` | string[] | yes | — | | | `commit.blocks.removed` | string[] | yes | — | | | `commit.relations` | object | yes | — | | | `commit.relations.resolved` | object[] | yes | — | | | `commit.relations.resolved[].rel_type` | string | yes | — | | | `commit.relations.resolved[].to` | string | yes | — | | | `commit.relations.unresolved` | object[] | yes | — | Edges whose target does not exist yet. Queued for resolution, never dropped (I3). | | `commit.relations.unresolved[].rel_type` | string | yes | — | | | `commit.relations.unresolved[].to` | string | yes | — | | | `commit.audit_id` | string | yes | — | | | `commit.index_queued` | boolean | yes | — | Whether an index job was enqueued for this commit. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `proposal-not-found` | application | `404` | No proposal exists at that address. | | `proposal-already-decided` | application | `409` | Somebody decided it first. A decision is final. | | `proposal-payload-invalid` | application | `409` | The stored payload is not a change that can be committed. It can be rejected. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/proposals/prop_1a2b3c4d5e6f708192a3b4c5d6e7f809/accept ``` ```json { "rationale": "Matches what we agreed in the review." } ``` ```json { "proposal": { "id": "prop_1a2b3c4d5e6f708192a3b4c5d6e7f809", "kind": "create", "target_permalink": "backend/rate-limits", "target_ref": "kmem://backend/rate-limits", "origin": "mcp", "session_id": "sess_7", "batch_id": "sess_7", "on_behalf_of": "usr_7c1e4b2a9d3f", "on_behalf_of_name": "ankur", "agent_client": "claude-code", "rationale": "Captured from the incident review.", "status": "accepted", "created_at": "2026-03-20T10:00:00.000Z", "decided_at": "2026-03-20T11:04:00.000Z", "decided_by": "usr_7c1e4b2a9d3f", "decided_by_name": "ankur", "payload": { "op": "create", "permalink": "backend/rate-limits", "markdown": "# Rate limits\n" }, "payload_error": null, "base_sha": null, "live_sha": "0b1c2d3e4f5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", "stale_base": false, "decision": "accepted", "decision_permalink": null, "replaced_by": null, "keep_both_available": true, "provenance": null }, "commit": { "outcome": "created", "note": { "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "title": "Auth decision", "folder": "backend", "type": "decision", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "created_at": "2026-03-14T09:22:00.000Z", "updated_at": "2026-03-20T11:04:00.000Z", "deleted_at": null, "freshness": 0.91, "half_life_days": 180, "visibility": "open", "restricted": false }, "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "blob_key": "blobs/9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a", "committed_at": "2026-03-20T11:04:00.000Z", "blocks": { "created": [ "blk_7f3a1b2c" ], "updated": [], "removed": [ "blk_0a1b2c3d" ] }, "relations": { "resolved": [ { "rel_type": "depends_on", "to": "kmem://backend/session-store" } ], "unresolved": [] }, "audit_id": "aud_4c1f9d6b8e2a47c3b5d9e0f1a2b3c4d5", "index_queued": true } } ``` --- ### `POST /api/proposals/{id}/reject` Decline a proposal. Stamps the row and writes an audit entry. Nothing is committed and nothing is deleted; the proposal stays in the table so the queue's history still reads. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The proposal id. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `rationale` | string | no | — | Why. Recorded in the audit log beside the decision. | | `based_on_sha` | string | no | — | The `content_sha` the reviewer compared a stale change against, on an accept. It must equal the live sha, and the commit then checks that sha rather than the one the change was proposed against — the same rule the batch endpoint applies. Ignored on a reject. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `proposal` | object | yes | — | | | `proposal.id` | string | yes | — | | | `proposal.kind` | `"create"` \| `"update"` \| `"delete"` | yes | — | What it asks to do. | | `proposal.target_permalink` | string \| null | yes | — | The note it would create, change or remove. | | `proposal.target_ref` | string \| null | yes | — | `kmem://permalink`, when it names one. | | `proposal.origin` | string | yes | — | Which door proposed it: mcp \| quickconnect \| ingest \| git. | | `proposal.session_id` | string \| null | yes | — | Groups a whole agent session for review as a unit. | | `proposal.batch_id` | string \| null | yes | — | The submission this proposal arrived in, set once at creation and never changed. `null` on a row written before the column existed; a client groups those by `session_id` and labels the group a session group rather than implying they were submitted together. | | `proposal.on_behalf_of` | string \| null | yes | — | The named person accountable for it: the `created_by` of the link it arrived through (ADR 0009), or the person who uploaded the file for an upload that would replace a doc. A reviewer needs to know on whose authority a queued write was made, not only what it says. | | `proposal.on_behalf_of_name` | string \| null | yes | — | The person `on_behalf_of` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `proposal.agent_client` | string \| null | yes | — | What the assistant called itself in this sitting: claude-code, cursor, chatgpt. Resolved from `session_id` against `provenance` first and the worklog second, and null when neither recorded one — a door that never said is a fact, not a name to invent. | | `proposal.rationale` | string \| null | yes | — | Why the agent thinks this is worth keeping. A historic upload's `Upload batch: imp_…` line is not part of it: that line is the row's grouping key, and it is sent as `batch_id`. | | `proposal.status` | `"pending"` \| `"accepted"` \| `"rejected"` | yes | — | | | `proposal.created_at` | string | yes | — | ISO-8601 UTC. | | `proposal.decided_at` | string \| null | yes | — | ISO-8601 UTC. | | `proposal.decided_by` | string \| null | yes | — | | | `proposal.decided_by_name` | string \| null | yes | — | The person `decided_by` names. For a withdrawal, the name of the connection that took the change back, or the name the assistant gave itself. Null when nothing names it. | | `proposal.payload` | object | yes | — | The proposed change, as it will be handed to CommitService. | | `proposal.payload_error` | string \| null | yes | — | Set when the stored payload is not a decodable change. Such a proposal can be rejected but not accepted. | | `proposal.base_sha` | string \| null | yes | — | The target's `content_sha` the proposal was written against, from the payload's own `expected_sha`. `null` when the door recorded none, which is a fact about the door rather than a claim that nothing moved. | | `proposal.live_sha` | string \| null | yes | — | The target's `content_sha` as it reads now. `null` when no live doc is there. | | `proposal.stale_base` | boolean | yes | — | True when the proposal is pending and `base_sha` and `live_sha` are both known and differ: somebody changed the doc after the proposal was made, the compare-and-swap in the commit would refuse the accept, and the reviewer has to load the latest version and judge it again. False whenever either sha is unknown, because `false` here means `not known to be stale` and a guess would be worse than an absence. Always false for an accepted or rejected proposal: a decided change has nothing left to compare, and after an accepted update the live sha is the proposal's own text. | | `proposal.decision` | `"accepted"` \| `"rejected"` \| `"keep_both"` \| `"withdrawn"` \| null | yes | — | Which resolution a reviewer chose. `keep_both` is an acceptance whose words landed at a new address instead of over the current doc, so `status` reads `accepted` for both. `withdrawn` means that nobody decided: the assistant that queued the change took it back before a person did. `status` reads `rejected` for it, and a client shows it as withdrawn and never as a person's rejection. `null` while the proposal is pending. | | `proposal.replaced_by` | string \| null | yes | — | For a withdrawn change, the id of a later change to the same address from the same batch or sitting: the assistant took this one back and sent that one instead. `null` for every other decision, and for a withdrawal that nothing replaced at that address. | | `proposal.decision_permalink` | string \| null | yes | — | The doc a `keep_both` created. `null` for every other decision. | | `proposal.keep_both_available` | boolean | yes | — | Whether a reviewer may keep both versions of this change. `false` for a deletion, which has no second version, and for a change recorded before submissions carried an id — that change has no batch address to post the decision to, so the control cannot work and the screen must not draw it. Posting `keep_both` on such an item answers 409 saying the same thing. | | `proposal.provenance` | object \| null | yes | — | Who wrote the doc this change targets, and whether anybody has checked it — the same object `GET /api/tree` and `GET /api/notes/{permalink}` carry. `null` when nothing stands at the address yet, which is every `create`: there is no doc to have provenance. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `proposal-not-found` | application | `404` | No proposal exists at that address. | | `proposal-already-decided` | application | `409` | Somebody decided it first. A decision is final. | **Example** ```http POST /api/proposals/prop_1a2b3c4d5e6f708192a3b4c5d6e7f809/reject ``` ```json { "rationale": "Transient debugging chatter rather than a decision." } ``` ```json { "proposal": { "id": "prop_1a2b3c4d5e6f708192a3b4c5d6e7f809", "kind": "create", "target_permalink": "backend/rate-limits", "target_ref": "kmem://backend/rate-limits", "origin": "mcp", "session_id": "sess_7", "batch_id": "sess_7", "on_behalf_of": "usr_7c1e4b2a9d3f", "on_behalf_of_name": "ankur", "agent_client": "claude-code", "rationale": "Captured from the incident review.", "status": "rejected", "created_at": "2026-03-20T10:00:00.000Z", "decided_at": "2026-03-20T11:04:00.000Z", "decided_by": "usr_7c1e4b2a9d3f", "decided_by_name": "ankur", "payload": { "op": "create", "permalink": "backend/rate-limits", "markdown": "# Rate limits\n" }, "payload_error": null, "base_sha": null, "live_sha": null, "stale_base": false, "decision": "rejected", "decision_permalink": null, "replaced_by": null, "keep_both_available": true, "provenance": null } } ``` --- ### `POST /api/proposals/batches/{batchId}/accept` Decide one whole submission, item by item. Validation is all-or-none and application is not, and the difference is stated rather than hidden. Every item is checked before anything is written — it belongs to this batch, it is still pending, its payload decodes, its base has not moved under it, its keep-both title is free — and one bad item refuses the whole request with a 409 naming it and why. Past that point the items are applied in order and each reports its own outcome, because an accept writes a blob to R2, rows to D1 and a job to a queue and there is no transaction across the three. Nothing here claims atomicity, and a client must not either. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `batchId` | The batch id, from the `batches` array of GET /api/proposals. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `decisions` | object[] | yes | — | One entry per proposal the reviewer decided. A proposal of this batch that is named by no entry stays pending and is counted in `totals.pending`. | | `decisions[].id` | string | yes | — | The proposal this decision is about. | | `decisions[].action` | `"accept"` \| `"reject"` \| `"keep_both"` | yes | — | `accept` commits the proposed change. `reject` records the refusal and leaves the doc as it is. `keep_both` keeps the current doc and commits the proposed text as a separate doc at `title`. The new doc records the proposal's author as the author of every block, also of the leading heading that `title` rewrites. | | `decisions[].title` | string | no | — | Required by `keep_both` and refused on every other action. The new doc's title. | | `decisions[].based_on_sha` | string | no | — | The `content_sha` the reviewer compared against. Required for an `accept` or a `keep_both` whose base is stale, and it must equal the live sha — that is what 'the reviewer loaded the latest version' means as a fact the server can check. Ignored on a `reject`. | | `rationale` | string | no | — | Why. Recorded in the audit log beside every decision in this batch. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `batch_id` | string | yes | — | | | `outcomes` | object[] | yes | — | One per item, in the order they were applied. | | `outcomes[].id` | string | yes | — | | | `outcomes[].outcome` | `"accepted"` \| `"rejected"` \| `"kept_both"` \| `"failed"` | yes | — | What happened to this one proposal. | | `outcomes[].permalink` | string \| null | yes | — | The doc the change landed at. The new address for a `kept_both`. | | `outcomes[].error` | string \| null | yes | — | Cause then fix, when this item failed. `null` when it went through. | | `totals` | object | yes | — | What the batch looks like now. `accepted + rejected + kept_both + failed` is the number of items sent. | | `totals.accepted` | integer | yes | — | | | `totals.rejected` | integer | yes | — | | | `totals.kept_both` | integer | yes | — | | | `totals.pending` | integer | yes | — | Proposals of this batch this request did not name, still waiting. | | `totals.failed` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `batch-not-found` | application | `404` | No proposal this session may read belongs to that batch. | | `batch-decision-invalid` | application | `409` | One item cannot be applied, and the sentence names it. Nothing in the batch was written. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/proposals/batches/bat_1a2b3c4d5e6f708192a3b4c5d6e7f809/accept ``` ```json { "decisions": [ { "id": "prop_1a2b3c4d5e6f708192a3b4c5d6e7f809", "action": "accept" }, { "id": "prop_2b3c4d5e6f708192a3b4c5d6e7f8091a", "action": "reject" }, { "id": "prop_3c4d5e6f708192a3b4c5d6e7f8091a2b", "action": "keep_both", "title": "Rate limits (proposed)" } ], "rationale": "Reviewed against the incident notes." } ``` ```json { "batch_id": "bat_1a2b3c4d5e6f708192a3b4c5d6e7f809", "outcomes": [ { "id": "prop_1a2b3c4d5e6f708192a3b4c5d6e7f809", "outcome": "accepted", "permalink": "backend/rate-limits", "error": null }, { "id": "prop_2b3c4d5e6f708192a3b4c5d6e7f8091a", "outcome": "rejected", "permalink": null, "error": null }, { "id": "prop_3c4d5e6f708192a3b4c5d6e7f8091a2b", "outcome": "kept_both", "permalink": "backend/rate-limits-proposed", "error": null } ], "totals": { "accepted": 1, "rejected": 1, "kept_both": 1, "pending": 0, "failed": 0 } } ``` --- ### `GET /api/tokens` Connections. An owner or an admin lists every connection in the organization; a member lists only the connections they made. There is no hash and no raw token on these records, and the absence is structural rather than a field somebody remembered to strip: the record type has no place to put one. `via` says whether a person minted the connection or an OAuth client got it by sign-in, and `oauth_client_name` names that client as it registered. An OAuth connection works only at this org's MCP endpoint and has no refresh token, so when it expires the person connects again in the client. `scope` is what a connection was made with and `effective_scope` is what it can do now: while the org has agent writes off, a `write` connection reads as `suggest` (ADR 0022). `agent_writes` gives the setting and whether this person may change it, so Connect can offer or disable the third permission. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `limit` | integer | no | `50` | | | `offset` | integer | no | `0` | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `links` | object[] | yes | — | Newest first. | | `links[].id` | string | yes | — | `tok_` and 32 hex characters. What revoke addresses, and what the audit log names. | | `links[].label` | string | yes | — | What a person calls it. Also the `actor` on every audit row it writes. | | `links[].scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | `read`, `suggest` or `write`, as the connection was made. Each implies the one below it. `suggest` sends every change to Review; `write` adds docs and edits the current version directly while the org has agent writes on. | | `links[].effective_scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | What this connection can do now: `scope`, limited by the org's agent writes setting. A `write` connection reads as `suggest` while agent writes are off (ADR 0022). | | `links[].status` | `"active"` \| `"expired"` \| `"revoked"` | yes | — | Derived from `expires_at` and `revoked_at` at read time, never stored. | | `links[].created_by` | string | yes | — | The account id of the person who minted it. The join key `provenance.on_behalf_of` copies. | | `links[].created_by_name` | string \| null | yes | — | The person `created_by` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `links[].created_at` | string | yes | — | ISO-8601 UTC. | | `links[].expires_at` | string \| null | yes | — | ISO-8601 UTC, or null for a link that never expires. | | `links[].revoked_at` | string \| null | yes | — | ISO-8601 UTC. | | `links[].last_used_at` | string \| null | yes | — | ISO-8601 UTC. | | `links[].use_count` | integer | yes | — | Authenticated requests this link has made. | | `links[].last_endpoint` | string \| null | yes | — | Which endpoint answered the last authenticated request: `browse`, `recall`, `AGENTS.md`, `mcp:tools/list`. A name, never a URL. Null until one has. | | `links[].activity` | object | yes | — | | | `links[].activity.authenticated` | boolean | yes | — | At least one request has authenticated with this connection. `use_count > 0`. | | `links[].activity.read` | boolean | yes | — | A request has listed or read the org's docs — `browse`, `read` or `recall`, on either door. Fetching a Quick Connect document or listing tools is not a read, and a later one does not clear it. | | `links[].activity.read_at` | string \| null | yes | — | When a request last listed or read the org's docs. ISO-8601 UTC. Null until one has. | | `links[].activity.received_at` | string \| null | yes | — | When kmem last received a request on this connection. ISO-8601 UTC. | | `links[].via` | `"oauth"` \| `"minted"` | yes | — | How the connection came to exist. `oauth`: a client such as ChatGPT or Claude got it when a person signed in and allowed it (ADR 0019); it works only at this org's MCP endpoint, and the person reconnects in that client when it expires. `minted`: a person created it in Connect or with `POST /api/tokens`. | | `links[].oauth_client_name` | string \| null | yes | — | The name the OAuth client registered with, such as `Claude`. A client chooses it and kmem does not verify it. Null for a minted connection. | | `page` | object | yes | — | | | `page.limit` | integer | yes | — | | | `page.offset` | integer | yes | — | | | `page.returned` | integer | yes | — | Rows in this response. | | `page.has_more` | boolean | yes | — | True when a full page came back, so there may be another. Not a total count. | | `agent_writes` | object | yes | — | | | `agent_writes.mode` | `"off"` \| `"on"` | yes | — | `off`: a connection can read, or read and propose, and every change waits in Review. `on`: a connection can also be made with `write` scope (ADR 0022). | | `agent_writes.can_change` | boolean | yes | — | Whether this person may change the setting in Org settings → Org: an owner or an admin of the org. Always false on a self-hosted instance, which has no org setting. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/tokens ``` ```json { "links": [ { "id": "tok_9a8b7c6d5e4f30211203f4e5d6c7b8a9", "label": "Ankur's laptop", "scope": "write", "effective_scope": "write", "status": "active", "created_by": "usr_7c1e4b2a9d3f", "created_by_name": "ankur", "created_at": "2026-03-14T09:22:00.000Z", "expires_at": "2026-03-21T09:22:00.000Z", "revoked_at": null, "last_used_at": "2026-03-20T11:04:00.000Z", "use_count": 42, "last_endpoint": "recall", "activity": { "authenticated": true, "read": true, "read_at": "2026-03-20T11:04:00.000Z", "received_at": "2026-03-20T11:04:00.000Z" }, "via": "minted", "oauth_client_name": null }, { "id": "tok_1f2e3d4c5b6a79880796a5b4c3d2e1f0", "label": "Claude research", "scope": "suggest", "effective_scope": "suggest", "status": "active", "created_by": "usr_7c1e4b2a9d3f", "created_by_name": "ankur", "created_at": "2026-03-15T08:00:00.000Z", "expires_at": "2026-04-14T08:00:00.000Z", "revoked_at": null, "last_used_at": "2026-03-20T10:30:00.000Z", "use_count": 7, "last_endpoint": "mcp:recall", "activity": { "authenticated": true, "read": true, "read_at": "2026-03-20T10:30:00.000Z", "received_at": "2026-03-20T10:30:00.000Z" }, "via": "oauth", "oauth_client_name": "Claude" } ], "page": { "limit": 50, "offset": 0, "returned": 2, "has_more": false }, "agent_writes": { "mode": "on", "can_change": true } } ``` --- ### `POST /api/tokens` Create a connection. The one response that carries a credential. Only the SHA-256 is stored (S1), so the raw `sp_...` is shown here and nowhere else, ever. `urls` carries every complete address this connection answers at — the Quick Connect URL, both Quick Connect documents and the MCP endpoint — built here so that no client reconstructs a credential from an id. `quick_connect_path` is the same Quick Connect URL as a path, kept for callers that already assemble it. The URL is the credential — treat it like a password, and prefer short expiries. A `write` connection is refused with `agent-writes-off` while the org has agent writes off (ADR 0022), and `session_link_mint_refused` at warn records who asked. **Success:** `201` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `label` | string | yes | — | Required. It is what the audit log records for every request this link makes. | | `scope` | `"read"` \| `"suggest"` \| `"write"` | no | `"read"` | `read`, `suggest` or `write`. `suggest` sends every change to Review. `write` adds docs and edits the current version directly, and is refused with `agent-writes-off` while the org has agent writes off. | | `reach` | `"open"` \| `"escorted"` | no | — | Ignored (ADR 0026). A connection reads what the person who makes it can read: every open doc, and every private doc whose list names that person. The field is accepted so that an older client still works. | | `expires_in` | string | no | `"7d"` | `1h` to `3650d`, or `never`. Prefer short expiries: the URL is the credential, and models echo URLs. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `link` | object | yes | — | | | `link.id` | string | yes | — | `tok_` and 32 hex characters. What revoke addresses, and what the audit log names. | | `link.label` | string | yes | — | What a person calls it. Also the `actor` on every audit row it writes. | | `link.scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | `read`, `suggest` or `write`, as the connection was made. Each implies the one below it. `suggest` sends every change to Review; `write` adds docs and edits the current version directly while the org has agent writes on. | | `link.effective_scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | What this connection can do now: `scope`, limited by the org's agent writes setting. A `write` connection reads as `suggest` while agent writes are off (ADR 0022). | | `link.status` | `"active"` \| `"expired"` \| `"revoked"` | yes | — | Derived from `expires_at` and `revoked_at` at read time, never stored. | | `link.created_by` | string | yes | — | The account id of the person who minted it. The join key `provenance.on_behalf_of` copies. | | `link.created_by_name` | string \| null | yes | — | The person `created_by` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `link.created_at` | string | yes | — | ISO-8601 UTC. | | `link.expires_at` | string \| null | yes | — | ISO-8601 UTC, or null for a link that never expires. | | `link.revoked_at` | string \| null | yes | — | ISO-8601 UTC. | | `link.last_used_at` | string \| null | yes | — | ISO-8601 UTC. | | `link.use_count` | integer | yes | — | Authenticated requests this link has made. | | `link.last_endpoint` | string \| null | yes | — | Which endpoint answered the last authenticated request: `browse`, `recall`, `AGENTS.md`, `mcp:tools/list`. A name, never a URL. Null until one has. | | `link.activity` | object | yes | — | | | `link.activity.authenticated` | boolean | yes | — | At least one request has authenticated with this connection. `use_count > 0`. | | `link.activity.read` | boolean | yes | — | A request has listed or read the org's docs — `browse`, `read` or `recall`, on either door. Fetching a Quick Connect document or listing tools is not a read, and a later one does not clear it. | | `link.activity.read_at` | string \| null | yes | — | When a request last listed or read the org's docs. ISO-8601 UTC. Null until one has. | | `link.activity.received_at` | string \| null | yes | — | When kmem last received a request on this connection. ISO-8601 UTC. | | `link.via` | `"oauth"` \| `"minted"` | yes | — | How the connection came to exist. `oauth`: a client such as ChatGPT or Claude got it when a person signed in and allowed it (ADR 0019); it works only at this org's MCP endpoint, and the person reconnects in that client when it expires. `minted`: a person created it in Connect or with `POST /api/tokens`. | | `link.oauth_client_name` | string \| null | yes | — | The name the OAuth client registered with, such as `Claude`. A client chooses it and kmem does not verify it. Null for a minted connection. | | `token` | string | yes | — | `sp_` and 32 base62 characters. Shown once, here. Only its SHA-256 is stored (S1). | | `quick_connect_path` | string | yes | — | Append this to the instance's origin to get the URL to paste into a chat. Built here so nobody assembles it wrongly. | | `warning` | string | yes | — | What to tell the person before they close the dialog. | | `urls` | object | yes | — | | | `urls.briefing` | string | yes | — | The bare link. Answers the briefing, and is what a chat client should be given. No extension: a URL ending `.md` is classified as a download by at least one chat client and handed to a sandbox with no network. | | `urls.agents_md` | string | yes | — | The briefing at its `AGENTS.md` spelling, for a file on disk. | | `urls.skills_md` | string | yes | — | The same connection as a skill file, with YAML front matter a skill loader reads. | | `urls.mcp` | string | yes | — | The MCP endpoint. Carries no credential: MCP takes the token as `Authorization: Bearer sp_…`. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `agent-writes-off` | application | `403` | The body asks for `write` scope and the org has agent writes off. Create the connection with `suggest`, or ask an owner or an admin to turn agent writes on in Org settings → Org. | **Example** ```http POST /api/tokens ``` ```json { "label": "Claude Code", "scope": "write", "expires_in": "7d" } ``` ```json { "link": { "id": "tok_9a8b7c6d5e4f30211203f4e5d6c7b8a9", "label": "Claude Code", "scope": "write", "effective_scope": "write", "status": "active", "created_by": "usr_7c1e4b2a9d3f", "created_by_name": "ankur", "created_at": "2026-03-14T09:22:00.000Z", "expires_at": "2026-03-21T09:22:00.000Z", "revoked_at": null, "last_used_at": null, "use_count": 0, "last_endpoint": null, "activity": { "authenticated": false, "read": false, "read_at": null, "received_at": null }, "via": "minted", "oauth_client_name": null }, "token": "sp_4kQ2mZ8xR1tV7bN0pL5cW3yH6dJ9sA2f", "quick_connect_path": "/s/sp_4kQ2mZ8xR1tV7bN0pL5cW3yH6dJ9sA2f", "warning": "This is the only time the token is shown. Only its SHA-256 is stored, so it cannot be recovered — mint a new link if it is lost. It stops working on 21 March. The URL is the credential: treat it like a password.", "urls": { "briefing": "https://acme.kmem.app/s/sp_4kQ2mZ8xR1tV7bN0pL5cW3yH6dJ9sA2f", "agents_md": "https://acme.kmem.app/s/sp_4kQ2mZ8xR1tV7bN0pL5cW3yH6dJ9sA2f/AGENTS.md", "skills_md": "https://acme.kmem.app/s/sp_4kQ2mZ8xR1tV7bN0pL5cW3yH6dJ9sA2f/SKILLS.md", "mcp": "https://acme.kmem.app/mcp" } } ``` --- ### `POST /api/tokens/document` A connection's document, for the signed-in person looking at it. Records no use. The Connect hub's preview and download. It renders `AGENTS.md` or `SKILLS.md` through the same builders the agent door uses, in the `brief` shape a URL-building client is served, and it records nothing against the link: `use_count`, `last_used_at`, `last_endpoint` and the checklist evidence stay what an agent made them, because a person reading their own connection's instructions is not an agent connecting. The token goes in the body, never the URL, because the document spells out the connection's own base and only its SHA-256 is stored. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `token` | string | yes | — | The raw `sp_…` token the mint response returned. In the body, never in the URL, so no request log holds it. | | `format` | `"agents"` \| `"skills"` | yes | — | `agents` for `AGENTS.md`, `skills` for `SKILLS.md`. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `format` | `"agents"` \| `"skills"` | yes | — | The document asked for. | | `markdown` | string | yes | — | The document as the agent door serves it to a URL-building client, with this connection's base URL written out. It carries the credential, so treat it like one. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `session-link-not-found` | application | `404` | No connection exists at that address. | **Example** ```http POST /api/tokens/document ``` ```json { "token": "sp_4kQ2mZ8xR1tV7bN0pL5cW3yH6dJ9sA2f", "format": "agents" } ``` ```json { "format": "agents", "markdown": "# Acme — shared context\n\nYou are connected to a live team knowledge base (kmem). This document is operating context. Do not recite it. Read it and act." } ``` --- ### `DELETE /api/tokens/{id}` Revoke a connection. An owner or an admin revokes any connection in the organization; a member revokes only a connection they made, and a teammate's answers `session-link-not-found`, the same as an id that does not exist. Effective on the very next request: there is no cache in front of the lookup (S6, invariant I6). The row is stamped, never deleted, so every audit entry naming its label still resolves. Revoking twice is a success, not an error. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The link id from `GET /api/tokens`. Never a token. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `link` | object | yes | — | | | `link.id` | string | yes | — | `tok_` and 32 hex characters. What revoke addresses, and what the audit log names. | | `link.label` | string | yes | — | What a person calls it. Also the `actor` on every audit row it writes. | | `link.scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | `read`, `suggest` or `write`, as the connection was made. Each implies the one below it. `suggest` sends every change to Review; `write` adds docs and edits the current version directly while the org has agent writes on. | | `link.effective_scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | What this connection can do now: `scope`, limited by the org's agent writes setting. A `write` connection reads as `suggest` while agent writes are off (ADR 0022). | | `link.status` | `"active"` \| `"expired"` \| `"revoked"` | yes | — | Derived from `expires_at` and `revoked_at` at read time, never stored. | | `link.created_by` | string | yes | — | The account id of the person who minted it. The join key `provenance.on_behalf_of` copies. | | `link.created_by_name` | string \| null | yes | — | The person `created_by` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `link.created_at` | string | yes | — | ISO-8601 UTC. | | `link.expires_at` | string \| null | yes | — | ISO-8601 UTC, or null for a link that never expires. | | `link.revoked_at` | string \| null | yes | — | ISO-8601 UTC. | | `link.last_used_at` | string \| null | yes | — | ISO-8601 UTC. | | `link.use_count` | integer | yes | — | Authenticated requests this link has made. | | `link.last_endpoint` | string \| null | yes | — | Which endpoint answered the last authenticated request: `browse`, `recall`, `AGENTS.md`, `mcp:tools/list`. A name, never a URL. Null until one has. | | `link.activity` | object | yes | — | | | `link.activity.authenticated` | boolean | yes | — | At least one request has authenticated with this connection. `use_count > 0`. | | `link.activity.read` | boolean | yes | — | A request has listed or read the org's docs — `browse`, `read` or `recall`, on either door. Fetching a Quick Connect document or listing tools is not a read, and a later one does not clear it. | | `link.activity.read_at` | string \| null | yes | — | When a request last listed or read the org's docs. ISO-8601 UTC. Null until one has. | | `link.activity.received_at` | string \| null | yes | — | When kmem last received a request on this connection. ISO-8601 UTC. | | `link.via` | `"oauth"` \| `"minted"` | yes | — | How the connection came to exist. `oauth`: a client such as ChatGPT or Claude got it when a person signed in and allowed it (ADR 0019); it works only at this org's MCP endpoint, and the person reconnects in that client when it expires. `minted`: a person created it in Connect or with `POST /api/tokens`. | | `link.oauth_client_name` | string \| null | yes | — | The name the OAuth client registered with, such as `Claude`. A client chooses it and kmem does not verify it. Null for a minted connection. | | `outcome` | `"revoked"` \| `"already-revoked"` | yes | — | `already-revoked` is a success: revoking twice changes nothing and is not an error. | | `effective` | string | yes | — | When the revocation takes effect, in words. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `session-link-not-found` | application | `404` | No connection exists at that address. | **Example** ```http DELETE /api/tokens/tok_9a8b7c6d5e4f30211203f4e5d6c7b8a9 ``` ```json { "link": { "id": "tok_9a8b7c6d5e4f30211203f4e5d6c7b8a9", "label": "Claude Code", "scope": "write", "effective_scope": "write", "status": "revoked", "created_by": "usr_7c1e4b2a9d3f", "created_by_name": "ankur", "created_at": "2026-03-14T09:22:00.000Z", "expires_at": "2026-03-21T09:22:00.000Z", "revoked_at": "2026-03-20T11:04:00.000Z", "last_used_at": "2026-03-20T11:00:00.000Z", "use_count": 42, "last_endpoint": "browse", "activity": { "authenticated": true, "read": true, "read_at": "2026-03-20T11:00:00.000Z", "received_at": "2026-03-20T11:00:00.000Z" }, "via": "minted", "oauth_client_name": null }, "outcome": "revoked", "effective": "Immediately. The next request that presents this link is refused; there is no cache in front of the lookup." } ``` --- ### `PATCH /api/tokens/{id}` Rename a connection. Changes the label and nothing else: the id, scope, expiry and counters stay as they were, so a rename can neither widen what a link may do nor reset the use count that shows a leak. The same door as revoking — an owner or an admin for any link in this project, and a member for a link they made, a revoked one included, so two rows with one name can be told apart. The label is held to a mint's rule: 1 to 60 characters after trimming, and never shaped like a credential, which would be redacted out of every audit row it appears in. One `token_renamed` audit row names the link by id and carries both labels, because every `token_used` row records the label rather than the id. Renaming to the label it already has answers `unchanged` and records nothing. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The link id from `GET /api/tokens`. Never a token. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `label` | string | yes | — | Required. The link's new name, which every later audit row records. Its id, scope, expiry and counters do not change. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `link` | object | yes | — | | | `link.id` | string | yes | — | `tok_` and 32 hex characters. What revoke addresses, and what the audit log names. | | `link.label` | string | yes | — | What a person calls it. Also the `actor` on every audit row it writes. | | `link.scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | `read`, `suggest` or `write`, as the connection was made. Each implies the one below it. `suggest` sends every change to Review; `write` adds docs and edits the current version directly while the org has agent writes on. | | `link.effective_scope` | `"read"` \| `"suggest"` \| `"write"` | yes | — | What this connection can do now: `scope`, limited by the org's agent writes setting. A `write` connection reads as `suggest` while agent writes are off (ADR 0022). | | `link.status` | `"active"` \| `"expired"` \| `"revoked"` | yes | — | Derived from `expires_at` and `revoked_at` at read time, never stored. | | `link.created_by` | string | yes | — | The account id of the person who minted it. The join key `provenance.on_behalf_of` copies. | | `link.created_by_name` | string \| null | yes | — | The person `created_by` names, by `personDisplayName`. Null when no account on this instance matches, and a client then shows the recorded string. | | `link.created_at` | string | yes | — | ISO-8601 UTC. | | `link.expires_at` | string \| null | yes | — | ISO-8601 UTC, or null for a link that never expires. | | `link.revoked_at` | string \| null | yes | — | ISO-8601 UTC. | | `link.last_used_at` | string \| null | yes | — | ISO-8601 UTC. | | `link.use_count` | integer | yes | — | Authenticated requests this link has made. | | `link.last_endpoint` | string \| null | yes | — | Which endpoint answered the last authenticated request: `browse`, `recall`, `AGENTS.md`, `mcp:tools/list`. A name, never a URL. Null until one has. | | `link.activity` | object | yes | — | | | `link.activity.authenticated` | boolean | yes | — | At least one request has authenticated with this connection. `use_count > 0`. | | `link.activity.read` | boolean | yes | — | A request has listed or read the org's docs — `browse`, `read` or `recall`, on either door. Fetching a Quick Connect document or listing tools is not a read, and a later one does not clear it. | | `link.activity.read_at` | string \| null | yes | — | When a request last listed or read the org's docs. ISO-8601 UTC. Null until one has. | | `link.activity.received_at` | string \| null | yes | — | When kmem last received a request on this connection. ISO-8601 UTC. | | `link.via` | `"oauth"` \| `"minted"` | yes | — | How the connection came to exist. `oauth`: a client such as ChatGPT or Claude got it when a person signed in and allowed it (ADR 0019); it works only at this org's MCP endpoint, and the person reconnects in that client when it expires. `minted`: a person created it in Connect or with `POST /api/tokens`. | | `link.oauth_client_name` | string \| null | yes | — | The name the OAuth client registered with, such as `Claude`. A client chooses it and kmem does not verify it. Null for a minted connection. | | `outcome` | `"renamed"` \| `"unchanged"` | yes | — | `unchanged` is a success: the link already had this label, so nothing was written and no audit row was added. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `session-link-not-found` | application | `404` | No connection exists at that address. | **Example** ```http PATCH /api/tokens/tok_9a8b7c6d5e4f30211203f4e5d6c7b8a9 ``` ```json { "label": "Claude Code on the build laptop" } ``` ```json { "link": { "id": "tok_9a8b7c6d5e4f30211203f4e5d6c7b8a9", "label": "Claude Code on the build laptop", "scope": "write", "effective_scope": "write", "status": "active", "created_by": "usr_7c1e4b2a9d3f", "created_by_name": "ankur", "created_at": "2026-03-14T09:22:00.000Z", "expires_at": "2026-03-21T09:22:00.000Z", "revoked_at": null, "last_used_at": "2026-03-20T11:00:00.000Z", "use_count": 42, "last_endpoint": "browse", "activity": { "authenticated": true, "read": true, "read_at": "2026-03-20T11:00:00.000Z", "received_at": "2026-03-20T11:00:00.000Z" }, "via": "minted", "oauth_client_name": null }, "outcome": "renamed" } ``` --- ### `GET /api/audit` Who did what, and a witness over the record. The append-only log security requirement S5 fills and `docs/OPERATIONS.md` tells an operator to read. Every response carries `integrity`, whose `unattributed` counts rows whose actor is `[redacted]` — defect #64's signature, and 101 rows on the production instance are that way permanently. `digest=true` adds a SHA-256 over every row in the window in `(at, id)` order: record it somewhere `audit_log` cannot be written from and any later modification, deletion or insertion changes it. There is no hash chain, because chaining needs a total order over appends that this system does not have and a chain stored in the table it protects only detects accident; see `docs/ADR/0010`. Any signed-in member may read this, the same bar as `GET /api/tokens` — but `integrity` and `digest` describe only the rows the caller's own scope may read (defect #109), so a member and an owner reading the same window can see different numbers. An owner's scope already covers every row, so the whole-log witness ADR 0010 designed this for still exists for the person it names. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `since` | integer | no | — | Unix epoch milliseconds. Only rows at or after this instant. | | `until` | integer | no | — | Unix epoch milliseconds. Only rows at or before this instant. | | `action` | string | no | — | Exact match on the snake_case verb, such as `note_created`. | | `actor` | string | no | — | Exact match on the user id, agent identity or session-link label. | | `limit` | integer | no | `100` | | | `offset` | integer | no | `0` | | | `digest` | `"true"` \| `"false"` | no | `"false"` | `true` also returns a SHA-256 over every row in the window, in `(at, id)` order. Off by default because it reads every row rather than a page. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `integrity` | object | yes | — | | | `integrity.entries` | integer | yes | — | Rows in the window, ignoring `limit` and `offset`. | | `integrity.oldest` | string \| null | yes | — | ISO-8601 UTC of the oldest row, or null. | | `integrity.newest` | string \| null | yes | — | ISO-8601 UTC of the newest row, or null. | | `integrity.unattributed` | integer | yes | — | Rows whose actor is `[redacted]`. Defect #64's signature. Above zero means the log has entries nobody can be held to, and they are not recoverable. | | `integrity.unaddressable` | integer | yes | — | Rows whose target holds `[redacted]`. Above zero means the log records acts without recording what they acted on, so a session view lists permalinks that address no note and undo skips them while reporting success. Not recoverable: the redaction happened before the insert. Zero is the exact statement — the count is a substring match, and a note may legally be filed at an address containing that word, so a positive number is a prompt to look rather than proof of damage. | | `integrity.actors` | integer | yes | — | Distinct actors in the window. | | `entries` | object[] | yes | — | | | `entries[].id` | string | yes | — | | | `entries[].at` | string | yes | — | ISO-8601 UTC. | | `entries[].actor` | string | yes | — | User id, agent identity, or the connection's name. Never a token. `[redacted]` means the row is unattributable; see `integrity.unattributed`. | | `entries[].actor_kind` | `"human"` \| `"agent"` | yes | — | | | `entries[].source` | string \| null | yes | — | web \| mcp \| quickconnect \| git \| ingest, or null when the stored value is none of them. | | `entries[].raw_source` | string | yes | — | Exactly what the column holds, whether or not it is recognised. | | `entries[].action` | string | yes | — | snake_case verb: `note_created`, `token_revoked`. | | `entries[].target` | string \| null | yes | — | A `kmem://` ref where one applies. | | `entries[].detail` | string \| null | yes | — | JSON, already redacted. Never contains a credential. | | `page` | object | yes | — | | | `page.limit` | integer | yes | — | | | `page.offset` | integer | yes | — | | | `page.returned` | integer | yes | — | Rows in this response. | | `page.has_more` | boolean | yes | — | True when a full page came back, so there may be another. Not a total count. | | `digest` | object \| null | yes | — | Null unless `digest=true` was asked for. | | `measured_in_ms` | number | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/audit?action=note_created&limit=2&digest=true ``` ```json { "integrity": { "entries": 143, "oldest": "2026-08-09T11:02:41.000Z", "newest": "2026-08-14T09:18:03.000Z", "unattributed": 101, "unaddressable": 0, "actors": 4 }, "entries": [ { "id": "aud_6f1c9b0a4d1e4a3f8c2b7e5d9a0f3c11", "at": "2026-08-14T09:18:03.000Z", "actor": "KJqwHRHKWj8nqeiyUMec56wUJ6m8bViR", "actor_kind": "human", "source": "web", "raw_source": "web", "action": "note_created", "target": "kmem://ops/deploy-target-for-the-first-live-instance", "detail": "{\"sessionId\":\"sess_9c1d\",\"blocks\":12}" } ], "page": { "limit": 2, "offset": 0, "returned": 1, "has_more": false }, "digest": { "encoding": 2, "from": "2026-08-09T11:02:41.000Z", "to": "2026-08-14T09:18:03.000Z", "entries": 143, "digest": "3f2a1c8e5b7d094612ac8f0e3b5d7192c4a6e8f0b2d4681a3c5e7092b4d6f8a1", "complete": true }, "measured_in_ms": 18 } ``` --- ### `GET /api/sessions` Every sitting an assistant wrote in, newest first. The same journal `GET /api/audit` returns, read as **sittings** rather than as rows. It exists because `docs/ADR/0016` removed the review queue from the default path of every assistant write: the audit log became the primary record of assistant activity, and until this route it had no reader anywhere in the product. Every answer is narrowed to what the caller's own scope may read, so a sitting that touched only notes they cannot open is not in the list at all (invariant I7) — which means two members can see different sittings, and that is the ladder working rather than a disagreement. `undone_at` is set only once an undo has reversed the **whole** sitting: an undo that refused or skipped any step leaves it null and the control stays on offer, and so does an undo run by a member who could see fewer of the sitting's writes than you can. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `limit` | integer | no | `50` | | | `offset` | integer | no | `0` | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `sessions` | object[] | yes | — | Newest first. | | `sessions[].session_id` | string | yes | — | The credential the writes were made on: the id of the connection, or of the MCP session it opened. It is a connection and not a visit, so it spans every run that credential ever made — which is also what the write budget counts and what an undo reverses. Revoke the connection to end it. | | `sessions[].first_at` | string | yes | — | ISO-8601 UTC. | | `sessions[].last_at` | string | yes | — | ISO-8601 UTC. | | `sessions[].actor` | string | yes | — | The connection's name, or the user id. Never a token, ever (S2). | | `sessions[].actor_name` | string \| null | yes | — | The person `actor` names, by `personDisplayName`. Null for an assistant's sitting, whose `actor` is its connection's label and is never looked up, and when no account on this instance matches. A sitting whose every write a person accepted is still the assistant's, named by its connection. The id stays on the wire because it is what reconciles against SQL by hand; the name arrives beside it and never instead of it, which is the rule `routes/api/worklog.ts` and `routes/api/trust.ts` already follow. | | `sessions[].actor_kind` | `"human"` \| `"agent"` | yes | — | Whether a person or an assistant made this sitting. It is the fact the screen's principal chip encodes in its colour, and a person's own editing sitting is in this list — so without it every row was painted as an assistant. Read off the same row of the audit log as `actor` and `on_behalf_of`, so the three are one row's answer and not three. | | `sessions[].on_behalf_of` | string \| null | yes | — | The named person accountable for an assistant's writes. Null for a sitting made by a person directly. | | `sessions[].on_behalf_of_name` | string \| null | yes | — | For an assistant's sitting, the person who owns its connection, by name. Null for a person's sitting and when no account matches. Never an account id. | | `sessions[].counts` | object | yes | — | | | `sessions[].counts.created` | integer | yes | — | | | `sessions[].counts.updated` | integer | yes | — | | | `sessions[].counts.deleted` | integer | yes | — | | | `sessions[].counts.verified` | integer | yes | — | Marks on how much the claims in a note are trusted. These change no byte of any note and are what takes one out of `recall`, so they are counted rather than folded into `updated`. | | `sessions[].undone_at` | string \| null | yes | — | ISO-8601 UTC of the undo that **wholly** reversed this sitting, or null. An undo that refused or skipped any step leaves this null, because the sitting is not reversed and the control must stay on offer. | | `page` | object | yes | — | | | `page.limit` | integer | yes | — | | | `page.offset` | integer | yes | — | | | `page.returned` | integer | yes | — | Rows in this response. | | `page.has_more` | boolean | yes | — | True when a full page came back, so there may be another. Not a total count. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/sessions?limit=2 ``` ```json { "sessions": [ { "session_id": "tok_8f2a11c4", "first_at": "2026-08-26T21:38:00.000Z", "last_at": "2026-08-26T21:50:00.000Z", "actor": "repo setup", "actor_name": null, "actor_kind": "agent", "on_behalf_of": "KJqwHRHKWj8nqeiyUMec56wUJ6m8bViR", "on_behalf_of_name": "Ankur Singh", "counts": { "created": 25, "updated": 3, "deleted": 0, "verified": 0 }, "undone_at": null }, { "session_id": "sess_3d90ab77", "first_at": "2026-08-26T20:02:00.000Z", "last_at": "2026-08-26T20:19:00.000Z", "actor": "KJqwHRHKWj8nqeiyUMec56wUJ6m8bViR", "actor_name": "Ankur Singh", "actor_kind": "human", "on_behalf_of": null, "on_behalf_of_name": null, "counts": { "created": 1, "updated": 4, "deleted": 0, "verified": 2 }, "undone_at": null } ], "page": { "limit": 2, "offset": 0, "returned": 2, "has_more": false } } ``` --- ### `GET /api/sessions/{id}` One sitting, one row per write. Every write the sitting made, oldest first, with the rationale the assistant gave for each. An assistant's write must carry one — `refuseAgentWriteWithoutRationale` — and under direct write that rationale is the audit row's only account of intent, so it is on the wire here. A sitting whose writes all landed on notes this caller may not read answers with an empty list rather than a 404: 'this session exists but is not yours' is itself a response about a note they may not read. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `session_id` | string | yes | — | | | `writes` | object[] | yes | — | Oldest first. | | `writes[].at` | string | yes | — | ISO-8601 UTC. | | `writes[].action` | `"note_created"` \| `"note_revived"` \| `"note_updated"` \| `"note_deleted"` \| `"block_unverified"` \| `"block_verified"` \| `"block_stale"` \| `"block_superseded"` | yes | — | | | `writes[].subject` | `"bytes"` \| `"claims"` | yes | — | `bytes` changed the note's Markdown; `claims` changed how much it is trusted. | | `writes[].permalink` | string | yes | — | | | `writes[].rationale` | string \| null | yes | — | Why the assistant said this was worth keeping. Mandatory on an agent write, so it is the audit row's account of intent. | | `permalinks` | string[] | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/sessions/tok_8f2a11c4 ``` ```json { "session_id": "tok_8f2a11c4", "writes": [ { "at": "2026-08-26T21:38:00.000Z", "action": "note_created", "subject": "bytes", "permalink": "handbook/onboarding", "rationale": "The repository README lists three accounts a joiner needs and nothing here did." } ], "permalinks": [ "handbook/onboarding" ] } ``` --- ### `GET /api/sessions/{id}/undo` What undoing this sitting would do. Writes nothing. The plan, so the confirmation a person reads names the notes rather than a count. One step per note and per subject, in reverse chronological order: `delete` tombstones a note the sitting created (invariant I2 — every byte stays in `blobs/`), `restore` puts back the bytes it replaced, `revive` brings back one it deleted, `reverify` puts back how much its claims were trusted, `none` is already as it was so nothing happens, and `skip` is reported and never touched. **A note somebody changed after the sitting is always a `skip`**, because an undo that silently overwrites a later human edit is the data loss PRD phase 6's gate forbids. `since` and `until` narrow the window, because the common real case is that the first ten writes were good — and a bound plans a named `skip` for every write it leaves out, because a narrowed undo is a partial undo and must never mark the sitting reversed. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `since` | integer | no | — | Unix epoch milliseconds. Only writes at or after this instant are undone. | | `until` | integer | no | — | Unix epoch milliseconds. Only writes at or before this instant are undone. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `session_id` | string | yes | — | | | `steps` | object[] | yes | — | | | `steps[].permalink` | string | yes | — | | | `steps[].subject` | `"bytes"` \| `"claims"` | yes | — | | | `steps[].outcome` | `"delete"` \| `"restore"` \| `"revive"` \| `"reverify"` \| `"none"` \| `"skip"` | yes | — | `delete` tombstones a note this sitting created; `restore` puts back the bytes it replaced; `revive` brings back one it deleted; `reverify` puts back how much its claims were trusted; `none` is already as it was, so nothing happens; `skip` is reported and never touched. | | `steps[].because` | string \| null | yes | — | Why a `skip`, a `none` or an inexact `reverify` says what it does. Else null. | | `counts` | object | yes | — | | | `counts.delete` | integer | yes | — | | | `counts.restore` | integer | yes | — | | | `counts.revive` | integer | yes | — | | | `counts.reverify` | integer | yes | — | | | `counts.none` | integer | yes | — | | | `counts.skip` | integer | yes | — | | | `pending_proposals` | integer | yes | — | How many of this sitting's writes are still waiting in the review queue. Executing the undo withdraws them, so that nobody can accept a reversed sitting's work afterwards. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/sessions/tok_8f2a11c4/undo ``` ```json { "session_id": "tok_8f2a11c4", "steps": [ { "permalink": "handbook/onboarding", "subject": "bytes", "outcome": "delete", "because": null }, { "permalink": "decisions/deploys", "subject": "bytes", "outcome": "skip", "because": "decisions/deploys was changed after this session wrote it, by session tok_b, so it is reported rather than undone. Undo that session first, then this one again." } ], "counts": { "delete": 1, "restore": 0, "revive": 0, "reverify": 0, "none": 0, "skip": 1 }, "pending_proposals": 0 } ``` --- ### `POST /api/sessions/{id}/undo` Reverse a whole sitting, in one act. Executes the plan. Every step is a commit through `CommitService` carrying the sha the sitting left, so a note touched between the plan and the execution is refused by the compare-and-swap rather than overwritten — and **a refused step does not stop the run**, because a person in an incident needs to know what is still standing. It runs under the caller's own scope, never the sitting's: a person who could not have written the note cannot unwrite it. It also **withdraws any proposal this sitting left in the review queue**, so nobody can accept a reversed sitting's work afterwards; the payload stays in the row and the rejection carries the person's name. The undo is **itself a sitting**, `undo_{id}`, so it can itself be undone; and it is idempotent, so pressing it twice changes nothing the second time and reports `unchanged`, never `undone`. `session_undone` is written as an audit row and logged at `warn`. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `since` | integer | no | — | Unix epoch milliseconds. Only writes at or after this instant are undone. | | `until` | integer | no | — | Unix epoch milliseconds. Only writes at or before this instant are undone. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `session_id` | string | yes | — | | | `undo_session_id` | string | yes | — | The sitting this undo wrote under. It is itself a sitting, so this undo can itself be undone. | | `steps` | object[] | yes | — | | | `steps[].permalink` | string | yes | — | | | `steps[].subject` | `"bytes"` \| `"claims"` | yes | — | | | `steps[].outcome` | `"deleted"` \| `"restored"` \| `"revived"` \| `"reverified"` \| `"unchanged"` \| `"skipped"` \| `"refused"` | yes | — | | | `steps[].because` | string \| null | yes | — | | | `undone` | integer | yes | — | | | `refused` | integer | yes | — | | | `skipped` | integer | yes | — | | | `unchanged` | integer | yes | — | Steps that needed nothing done. Counted, because it was counted nowhere: `unchanged` fell between `undone` and `refused`, so an undo whose every step was a no-op answered `undone: 1` and the response was false. | | `withdrawn` | integer | yes | — | Proposals this sitting left in the review queue, withdrawn by this undo. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `session-not-found` | application | `404` | A member asked to undo a sitting they do not own. A member undoes their own sittings and those of the connections they made; an owner or an admin undoes any. | **Example** ```http POST /api/sessions/tok_8f2a11c4/undo ``` ```json { "session_id": "tok_8f2a11c4", "undo_session_id": "undo_tok_8f2a11c4", "steps": [ { "permalink": "handbook/onboarding", "subject": "bytes", "outcome": "deleted", "because": null }, { "permalink": "decisions/deploys", "subject": "bytes", "outcome": "skipped", "because": "decisions/deploys was changed after this session wrote it, by session tok_b, so it is reported rather than undone. Undo that session first, then this one again." } ], "undone": 1, "refused": 0, "skipped": 1, "unchanged": 0, "withdrawn": 0 } ``` --- ### `GET /api/org/{orgId}/defaults` What assistants may do across this organization, and what actually governs. The two capability columns as they are stored, what the live permission resolver reads for them, and the value in force — side by side, on purpose. Until 2026-08-28 the resolver joined an organization id against a project id, matched no row, and discarded every stored organization default while the setting page went on showing what had been typed; a getter that returned only the row could not have shown that. So `stored` is the `org_routing` row read directly, `resolved` is what `storedCapabilityChain` reads, and `why` names every reason the effective value is not the one on this page — a project whose own value decides for its knowledge base, in either direction, since the assistant column lets a child widen as well as narrow; an instance `SIGNPOST_WRITE_POLICY` that caps every organization; or nothing stored at all. `differs` true with an empty `why` cannot happen: the last branch says so in words rather than leaving a person to guess. `effective` is an organization-scope ceiling and never a grant: a link's scope and the acting person's own column are folded in later, by `effectiveAgentCapability`, and both can only narrow it. Any member of the organization may read this — access is not a secret from the people it governs — and only an owner may change it. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `stored` | object | yes | — | The `org_routing` row as it is stored, read directly. The same shape PATCH answers with. | | `stored.assistants` | `"none"` \| `"read"` \| `"suggest"` \| `"write"` \| `"never"` | yes | — | | | `stored.people` | `"none"` \| `"read"` \| `"suggest"` \| `"write"` \| null | yes | — | | | `resolved` | object | yes | — | What the resolver reads, level by level. Every field is one query. | | `resolved.organization` | `"never"` \| `"none"` \| `"read"` \| `"suggest"` \| `"write"` \| null | yes | — | What the live capability resolver reads for this organization. Must equal `stored.assistants`; where it does not, the resolver is not reading the row and `why` says so. | | `resolved.project` | `"never"` \| `"none"` \| `"read"` \| `"suggest"` \| `"write"` \| null | yes | — | The narrowest `project.capability_assistants` in this organization, or null when no project authored one. A project narrows; it never widens the organization. | | `resolved.instance_ceiling` | `"never"` \| `"none"` \| `"read"` \| `"suggest"` \| `"write"` | yes | — | SIGNPOST_WRITE_POLICY restated as a capability. It tightens and never widens (ADR 0016, departure 1), so an operator can lower it in one redeploy during an incident. | | `resolved.session_write_budget` | integer \| null | yes | — | Applied direct writes one connection may make before every further write queues, or null for no budget, the default since 2026-10-08. Read by the clamp; no interface writes it (ADR 0016, control 2). | | `effective` | object | yes | — | The organization-scope answer. It is a ceiling on what any assistant may do here, never a grant: `effectiveAgentCapability` folds the link and the person in, and both can only narrow. | | `effective.assistants` | `"never"` \| `"none"` \| `"read"` \| `"suggest"` \| `"write"` | yes | — | What governs an ordinary open note in this organization today, before a link's scope and the acting person's own column narrow it further. | | `effective.set_by` | `"org"` \| `"project"` \| `"space"` \| `"note"` | yes | — | The outermost level that reaches the value above. Ties go outermost. | | `differs` | boolean | yes | — | True when `effective.assistants` is not `stored.assistants`. `why` says why. | | `why` | string[] | yes | — | One sentence per reason the value in force is not simply the value on this page. Empty is a real answer and the common one: it means this row governs and nothing narrowed or widened it. `differs` true with an empty `why` cannot happen — the page never says the value changed without saying what changed it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/org/org_9f2c/defaults ``` ```json { "stored": { "assistants": "write", "people": null }, "resolved": { "organization": "write", "project": null, "instance_ceiling": "suggest", "session_write_budget": null }, "effective": { "assistants": "suggest", "set_by": "org" }, "differs": true, "why": [ "SIGNPOST_WRITE_POLICY is \"review\" on this deployment, which caps every assistant at suggest whatever any organization stores. Change the variable and redeploy to raise the cap; nothing on this page can raise it." ] } ``` --- ### `PATCH /api/org/{orgId}/defaults` What people and assistants may do across a whole organization. The outermost settable values in the two-grid model, and the row the onboarding wizard writes. Setting `assistants` to `write` is what makes an assistant's change apply without a person accepting it (`docs/ADR/0016`); setting it to `suggest` is how a person gets the review queue back for the whole organization, in one request. `never` is not offered here — it is a per-node floor, not an organization default, and an organization closed to every assistant is a knowledge base with the product turned off. `people` is accepted and stored, and nothing reads it yet. The change is logged as `org_defaults_changed` at `warn` with the person's id: a permission changed with no record of who changed it is the failure this wave is fixing elsewhere. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `assistants` | `"none"` \| `"read"` \| `"suggest"` \| `"write"` | yes | — | What an assistant may do, unless a project, a space or a note narrows it. `write` means its changes go live at once; `suggest` means each one waits for a person. | | `people` | `"none"` \| `"read"` \| `"suggest"` \| `"write"` | no | — | What a person may do. Accepted and stored; nothing reads it yet, so `DEFAULT_CAPABILITY` still governs (`design/DESIGN.md`, provenance and status). | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `assistants` | `"none"` \| `"read"` \| `"suggest"` \| `"write"` \| `"never"` | yes | — | | | `people` | `"none"` \| `"read"` \| `"suggest"` \| `"write"` \| null | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http PATCH /api/org/org_9f2c/defaults ``` ```json { "assistants": "write" } ``` ```json { "assistants": "write", "people": null } ``` --- ### `GET /api/trust` How much of this knowledge base a person has checked, and whose it is. PRD phase 7's trust report, whose manual gate is "read it and confirm the numbers match reality" — so every field is a count of rows and says which rows, and there is no score, no gauge and no grade. `verification` groups `provenance` by state; `waymarks` groups the same rows by the six states the glyph beside every claim draws, which is the cross-product of author kind and verification state that neither `verification` nor `authorship` can express alone and which sums to `verification.total`; `authorship` groups them by author kind and counts the blocks with no provenance row at all; `freshness` buckets each claim at one and two half-lives of its own note type, which is the discount retrieval actually applies (PRD 4.2); `review` is the queue by status. Every count is scoped to the reader: two members with different reach get different numbers, and that is the visibility ladder working rather than a disagreement. `caveats` is what the numbers do not say — empty is a real answer, and a non-empty list is the report telling on itself. **Success:** `200` **Response** PRD phase 7. Every field is one query, reconcilable by hand. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | ISO-8601 UTC. Every age below is relative to this. | | `write_policy` | `"open"` \| `"conflicts"` \| `"review"` \| `"locked"` | yes | — | What happens when an assistant writes: applied, queued, or refused. `conflicts` is both: a new doc from an agent lands in Docs, and a change to an existing doc waits in Review for a person. | | `notes` | integer | yes | — | Live notes in reach. Excludes tombstones and _folder.md. | | `verification` | object | yes | — | | | `verification.unverified` | integer | yes | — | Nobody has checked it. An assistant's write, a sync from a repository and a history restore start here; a person's own write starts verified. | | `verification.verified` | integer | yes | — | A person recorded that they checked it. | | `verification.accepted` | integer | yes | — | A person accepted the words through a review decision, and nobody has ruled on the line since. Disjoint from unverified. | | `verification.stale` | integer | yes | — | A person recorded that it has gone out of date. | | `verification.superseded` | integer | yes | — | Replaced. Excluded from every default recall. | | `verification.total` | integer | yes | — | Attributed blocks in reach. The denominator below. | | `verification.verified_share` | number | yes | — | `verified / total`, in [0, 1]. `0` when there is nothing. | | `waymarks` | object | yes | — | | | `waymarks.human-verified` | integer | yes | — | Trail green, filled chevron. | | `waymarks.human-unverified` | integer | yes | — | Trail green, hollow chevron. | | `waymarks.agent-verified` | integer | yes | — | Slate, filled — an assistant wrote it, a person checked it. | | `waymarks.agent-accepted` | integer | yes | — | Assistant blue, cored chevron — a person accepted the note into recall without checking each line. | | `waymarks.agent-unverified` | integer | yes | — | Slate, hollow. | | `waymarks.stale` | integer | yes | — | Caution amber, hollow. A person marked it stale, or it is past one half-life and unverified. | | `waymarks.conflict` | integer | yes | — | Alert red, cross. Superseded or conflicting. | | `authorship` | object | yes | — | | | `authorship.human` | integer | yes | — | | | `authorship.agent` | integer | yes | — | | | `authorship.agent_share` | number | yes | — | `agent / (human + agent)`, in [0, 1]. | | `authorship.unattributed` | integer | yes | — | Blocks with no provenance row. Should be zero; non-zero means the index disagrees with itself. | | `authors` | object[] | yes | — | Biggest first, capped. | | `authors[].author` | string \| null | yes | — | `author_id` as recorded, or null where none was. | | `authors[].author_name` | string \| null | yes | — | The person `author` names, by `personDisplayName`. Null for an agent, whose `author` is a label and is never looked up, and when no account matches. | | `authors[].kind` | `"human"` \| `"agent"` | yes | — | | | `authors[].blocks` | integer | yes | — | | | `authors[].on_behalf_of` | string \| null | yes | — | For an agent, the person accountable for the link it wrote through (ADR 0009). | | `authors[].on_behalf_of_name` | string \| null | yes | — | For an agent, the person who owns its connection, by name. Null for a person and when no account matches. Never an account id. | | `agent_heavy_folders` | object[] | yes | — | Folders where an assistant has written more blocks than a person has. | | `agent_heavy_folders[].folder` | string | yes | — | "" for the root. | | `agent_heavy_folders[].human` | integer | yes | — | | | `agent_heavy_folders[].agent` | integer | yes | — | | | `freshness` | object | yes | — | | | `freshness.current` | integer | yes | — | Younger than one half-life for its note type. | | `freshness.ageing` | integer | yes | — | Between one and two half-lives. | | `freshness.stale` | integer | yes | — | Older than two half-lives. Freshness multiplier 0.135. | | `freshness.by_type` | object[] | yes | — | | | `freshness.by_type[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `freshness.by_type[].half_life_days` | number | yes | — | `RetrievalConfig.halfLives`, PRD 4.2. | | `freshness.by_type[].current` | integer | yes | — | | | `freshness.by_type[].ageing` | integer | yes | — | | | `freshness.by_type[].stale` | integer | yes | — | | | `review` | object | yes | — | | | `review.pending` | integer | yes | — | | | `review.accepted` | integer | yes | — | | | `review.rejected` | integer | yes | — | | | `review.oldest_pending_age_days` | number \| null | yes | — | | | `supersession` | object | yes | — | | | `supersession.blocks` | integer | yes | — | | | `supersession.notes` | object[] | yes | — | | | `supersession.notes[].permalink` | string | yes | — | | | `supersession.notes[].ref` | string | yes | — | | | `supersession.notes[].blocks` | integer | yes | — | | | `caveats` | string[] | yes | — | What the numbers above do not say. Empty is a real answer; non-empty is the report telling on itself. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/trust ``` ```json { "generated_at": "2026-08-15T09:12:44.000Z", "write_policy": "conflicts", "notes": 24, "verification": { "unverified": 1902, "accepted": 0, "verified": 21, "stale": 0, "superseded": 4, "total": 1927, "verified_share": 0.0109 }, "waymarks": { "human-verified": 21, "human-unverified": 1869, "agent-verified": 0, "agent-accepted": 0, "agent-unverified": 0, "stale": 33, "conflict": 4 }, "authorship": { "human": 1927, "agent": 0, "agent_share": 0, "unattributed": 0 }, "authors": [ { "author": "owjfL22vKdDunQM3yOzmCtEF28ixedyd", "author_name": "ankur", "kind": "human", "blocks": 1927, "on_behalf_of": null, "on_behalf_of_name": null } ], "agent_heavy_folders": [], "freshness": { "current": 1890, "ageing": 33, "stale": 4, "by_type": [ { "type": "note", "half_life_days": 90, "current": 120, "ageing": 0, "stale": 0 }, { "type": "decision", "half_life_days": 180, "current": 44, "ageing": 0, "stale": 0 }, { "type": "convention", "half_life_days": 365, "current": 61, "ageing": 0, "stale": 0 }, { "type": "state", "half_life_days": 14, "current": 12, "ageing": 33, "stale": 4 }, { "type": "reference", "half_life_days": 180, "current": 1653, "ageing": 0, "stale": 0 } ] }, "review": { "pending": 0, "accepted": 0, "rejected": 0, "oldest_pending_age_days": null }, "supersession": { "blocks": 4, "notes": [ { "permalink": "signpost/old-plan", "ref": "kmem://signpost/old-plan", "blocks": 4 } ] }, "caveats": [ "1% of claims in reach have been verified by a person. The rest are unreviewed. An assistant's write, a restore and a sync from a repository all start there, so this is not an accusation." ] } ``` --- ### `GET /api/usage/hot-stale` Notes recall keeps serving that nobody has checked lately. PRD_PART2 F7. `staleness` is `usage × (days since the note was last written or verified ÷ the days that note type stays fresh)`, where `usage` is recall hits plus reads since the note's ninety-day window opened. Usage is a multiplier rather than a term, so a note nothing has asked for scores zero however old it is — half of a healthy knowledge base is old on purpose, and a queue listing every ancient note is a queue nobody opens twice. Only notes at or above 1.0 appear. `has_open_task` says whether a person has already been asked; the loop that asks them runs on the tail of every recall, so this list and `GET /api/usage/verify-tasks` agree without a cron. Every live note is *scored*, not only the reader's own reach, because a hot-stale queue that hid the private notes from an operator would hide exactly the ones they most need to check — but the *response* to this call carries only the notes the caller's own read scope may open (defect #110), so a member and an owner asking at the same moment can see different lists. No note text is returned, only a permalink and a title. **Success:** `200` **Response** PRD_PART2 F7: notes that are hot in recall and older than their type stays fresh. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | ISO-8601 UTC. Every age below is relative to this. | | `notes` | object[] | yes | — | Most stale first. | | `notes[].permalink` | string | yes | — | | | `notes[].title` | string | yes | — | | | `notes[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `notes[].staleness` | number | yes | — | `usage × (age in days ÷ the type's fresh-for days)`. At or above 1 to appear here. | | `notes[].usage` | integer | yes | — | Recall hits plus reads since the usage window opened. | | `notes[].recall_hits` | integer | yes | — | Lifetime count of context packs this note appeared in. | | `notes[].reads` | integer | yes | — | Lifetime count of whole-note reads. | | `notes[].age_days` | integer | yes | — | Days since the note was last written or verified. | | `notes[].updated_at` | integer | yes | — | Unix epoch milliseconds. | | `notes[].last_recalled_at` | integer \| null | yes | — | | | `notes[].has_open_task` | boolean | yes | — | True when a person has already been asked to check it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/usage/hot-stale?limit=2 ``` ```json { "generated_at": "2026-08-18T09:12:44.000Z", "notes": [ { "permalink": "ops/oncall-rotation", "title": "On-call rotation", "type": "state", "staleness": 22.5, "usage": 9, "recall_hits": 31, "reads": 4, "age_days": 35, "updated_at": 1753000000000, "last_recalled_at": 1755900000000, "has_open_task": true } ] } ``` --- ### `GET /api/usage/never-recalled` Notes nothing has ever asked for, oldest first. PRD_PART2 F7's other list, and a different verb: nothing here gets verified, because there is no evidence anybody needs the answer. These are candidates for rewriting or for deletion and a person decides which. A note recalled once two years ago is **not** here — somebody needed it once, and the honest summary of that is "rarely used", not "unused". The cut-off is sixty days since the note was created. Carries only notes the caller's own read scope may open (defect #110); a member and an owner reading at the same moment can see different lists. **Success:** `200` **Response** PRD_PART2 F7: candidates for pruning or rewriting, not for verifying. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | | | `notes` | object[] | yes | — | Oldest first. Nothing has ever recalled or read one of these. | | `notes[].permalink` | string | yes | — | | | `notes[].title` | string | yes | — | | | `notes[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `notes[].created_at` | integer | yes | — | Unix epoch milliseconds. | | `notes[].age_days` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/usage/never-recalled?limit=2 ``` ```json { "generated_at": "2026-08-18T09:12:44.000Z", "notes": [ { "permalink": "archive/2024-planning", "title": "2024 planning", "type": "note", "created_at": 1720000000000, "age_days": 416 } ] } ``` --- ### `GET /api/usage/verify-tasks` The verify queue: what to check, and what was being quoted from it. PRD_PART2 F7. A task opens when a note's staleness crosses 1.0 while it is being served, at most one open per note, and it closes when somebody calls the `verify` verb on that note's ref. `claims` is what the note was serving at the moment the task opened, so a reviewer sees the sentences an assistant has been quoting rather than only an address — which is literal, quoted note prose, so this list carries only tasks on notes the caller's own read scope may open (defect #110). A member and an owner reading at the same moment can see different tasks, and a note this session could not open by address never appears here either. `reason` is one sentence with the numbers in it. Closing a task changes no byte of the note: `verify` stamps `provenance.verified_at` and leaves the words alone. **Success:** `200` **Response** PRD_PART2 F7: the queue, drained by calling verify on each ref. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | | | `tasks` | object[] | yes | — | Open tasks, most stale first. | | `tasks[].id` | string | yes | — | | | `tasks[].ref` | string | yes | — | `kmem://folder/slug`. Pass it to the verify verb to close this. | | `tasks[].permalink` | string | yes | — | | | `tasks[].reason` | string | yes | — | One sentence: what made this note stale, with the numbers. | | `tasks[].staleness` | number | yes | — | The score when the task opened, kept so the queue sorts. | | `tasks[].claims` | object[] | yes | — | What was being served from this note when the task opened. | | `tasks[].claims[].block_id` | string | yes | — | | | `tasks[].claims[].text` | string | yes | — | | | `tasks[].opened_at` | integer | yes | — | Unix epoch milliseconds. | | `tasks[].title` | string \| null | yes | — | The note's title now. Null when the note went away between the task read and the note read, which is a real answer and not an empty string. | | `tasks[].kind` | `"hot-stale"` | yes | — | Why this ask exists. One value today, and by construction: the staleness pass in `recordServedNotes` is the only opener the product has. The other five kinds in `specs/proposals/verification.md` §4.1 need a `verify_signals` table that does not exist. | | `tasks[].evidence` | object | yes | — | Why a person is being asked, in one sentence they can check, with the facts behind it read now and a link to where they check them. | | `tasks[].evidence.sentence` | string | yes | — | The one sentence the opener wrote, quoted verbatim. Written once, at `written_at`, and never recomposed — check it against `facts`, which are read now. | | `tasks[].evidence.written_at` | string | yes | — | ISO-8601 UTC. When `sentence` was composed, which is when the ask opened. | | `tasks[].evidence.still_true` | boolean | yes | — | False when the note has been written or verified since the ask opened, so `sentence` describes a state that has moved and the stored claims may no longer be the note's words. | | `tasks[].evidence.facts` | object | yes | — | Read at this request, never at open time. Every field is one query. | | `tasks[].evidence.facts.staleness_at_open` | number | yes | — | The score when the ask opened, as stored. | | `tasks[].evidence.facts.staleness_now` | number | yes | — | The same score recomputed at this request, through `stalenessOf`. | | `tasks[].evidence.facts.usage` | integer | yes | — | Recall hits plus reads since the usage window opened. | | `tasks[].evidence.facts.recall_hits` | integer | yes | — | Lifetime count of context packs this note was in. | | `tasks[].evidence.facts.reads` | integer | yes | — | Lifetime count of whole-note reads. | | `tasks[].evidence.facts.age_days` | integer | yes | — | Days since the note was last written or verified, whichever is later. | | `tasks[].evidence.facts.note_updated_at` | string | yes | — | ISO-8601 UTC. | | `tasks[].evidence.facts.last_verified_at` | string \| null | yes | — | ISO-8601 UTC, or null when nobody has ever checked this note. | | `tasks[].evidence.link` | object | yes | — | Where a person checks the sentence. | | `tasks[].evidence.link.ref` | string | yes | — | `kmem://folder/slug` — the note this ask is about. | | `tasks[].evidence.link.note` | string | yes | — | The API path that returns the note and its claims. | | `tasks[].evidence.link.audit` | string | yes | — | The API path that returns every recorded act on this note. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/usage/verify-tasks ``` ```json { "generated_at": "2026-08-18T09:12:44.000Z", "tasks": [ { "id": "vt_9f2c41ab7d0e4c8fa1b3c5d7e9f0a2b4", "ref": "kmem://ops/oncall-rotation", "permalink": "ops/oncall-rotation", "reason": "This state note was served 9 time(s) in the last 90 days and was last written 35 days ago, which is 2.5× the 14 days a state note stays fresh. Read it and call verify, which records that it is still true without changing a word of it.", "staleness": 22.5, "claims": [ { "block_id": "blk_3f2a91c7", "text": "On-call rotates on Mondays at 10:00 Europe/London." } ], "opened_at": 1755900000000, "title": "On-call rotation", "kind": "hot-stale", "evidence": { "sentence": "This state note was served 9 time(s) in the last 90 days and was last written 35 days ago, which is 2.5× the 14 days a state note stays fresh. Read it and call verify, which records that it is still true without changing a word of it.", "written_at": "2026-08-18T09:00:00.000Z", "still_true": true, "facts": { "staleness_at_open": 22.5, "staleness_now": 22.5, "usage": 9, "recall_hits": 8, "reads": 1, "age_days": 35, "note_updated_at": "2026-07-14T09:00:00.000Z", "last_verified_at": null }, "link": { "ref": "kmem://ops/oncall-rotation", "note": "/api/notes/ops/oncall-rotation", "audit": "/api/audit?target=kmem://ops/oncall-rotation" } } } ] } ``` --- ### `GET /api/usage/import-batches` What could be cleared in one bulk verify, largest first. The trust surface's bulk-verify-by-import picker. "Imported from one source on one date" is read off `provenance.source` and the UTC day of `provenance.created_at` rather than a stored batch id — the same fact a person could compute by hand against `provenance`. Only unverified claims in the caller's own read scope are counted, so this list and what `POST /api/usage/verify {"target":"import",...}` can actually clear always agree. **Success:** `200` **Response** The bulk-verify picker's source list: what a person could clear with one `target: "import"` call. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | | | `batches` | object[] | yes | — | Largest first. | | `batches[].source` | `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` | yes | — | | | `batches[].date` | string | yes | — | `YYYY-MM-DD`, UTC — the day the batch's provenance rows were written. | | `batches[].notes` | integer | yes | — | Live notes in reach with at least one unverified claim from this batch. | | `batches[].unverified_blocks` | integer | yes | — | Unverified claims in reach in this batch. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/usage/import-batches ``` ```json { "generated_at": "2026-08-19T09:12:44.000Z", "batches": [ { "source": "ingest", "date": "2026-08-15", "notes": 42, "unverified_blocks": 168 } ] } ``` --- ### `POST /api/usage/verify` Mark checked: one note, a folder, or one import batch. The trust surface's write path (PRD_PART2 F7). `target: "note"` is the one-click case — every block of one note, or a single claim when `block_id` narrows it, which is what "expand to block level" sends. `target: "folder"` is "verify this space," always recursive. `target: "import"` is "everything imported from one source on one date" — see `GET /api/usage/import-batches` for what exists. Every target is resolved through the caller's own read scope before anything is touched, so a `folder` or `import` batch simply never includes a note it may not read — nothing is filtered afterwards, so the counts in the response cannot be used to learn what exists outside that reach. `target: "note"` is the one shape that refuses outright: a note or block this session cannot open answers 404, never 403, which is invariant I7's usual shape. Always authenticated as `author_kind: human`: this door exists because the published `verify` tool and Quick Connect's `$BASE/verify` both authenticate as an assistant, which is the wrong attribution for a person clicking a button. `notes_verified` and `blocks_verified` report only what actually moved; `skipped` names anything a bulk request could not act on and why, never a reason to fail the rest. Changes no byte of any note. **Success:** `200` **Request body** *When `target` is "note":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `target` | `"note"` | yes | — | | | `permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `block_id` | string | no | — | Verify one claim instead of every block of the note — 'expand to block level'. | | `note` | string | no | — | One optional sentence the person leaves. | *When `target` is "folder":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `target` | `"folder"` | yes | — | | | `folder` | string | yes | — | `""` is the top level. Always recursive. | | `note` | string | no | — | | *When `target` is "import":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `target` | `"import"` | yes | — | | | `source` | `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` | yes | — | | | `date` | string | yes | — | The UTC calendar day the batch's provenance rows were written. | | `note` | string | no | — | | **Response** PRD_PART2 F7 / the trust surface. Stamps provenance.verified_at and verified_by; changes no byte of any note. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | | | `target` | `"note"` \| `"folder"` \| `"import"` | yes | — | | | `notes_verified` | integer | yes | — | | | `blocks_verified` | integer | yes | — | | | `verify_tasks_closed` | integer | yes | — | | | `skipped` | object[] | yes | — | | | `skipped[].permalink` | string | yes | — | | | `skipped[].reason` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `block-not-found` | application | `404` | No block exists at that address. | | `nothing-to-verify` | application | `409` | `target: "note"` named a live note with no blocks. | **Example** ```http POST /api/usage/verify ``` ```json { "target": "note", "permalink": "ops/oncall-rotation" } ``` ```json { "generated_at": "2026-08-19T09:12:44.000Z", "target": "note", "notes_verified": 1, "blocks_verified": 4, "verify_tasks_closed": 1, "skipped": [] } ``` --- ### `POST /api/verify` Move a claim, a section or a whole note between the four states. The status record's own door. `POST /api/usage/verify` sweeps notes, folders and import batches to `verified` and can say nothing else; this moves the claims of **one** note to any of the four states, which is what a person reading that note is in a position to know. Both doors commit through one function — `transitionClaims` in `services/trust.ts` — so there is one place that stamps `provenance`, writes the audit row and closes a verify task. `scope: "claims"` carries an explicit list: one id is a single claim, and the run of ids under one heading is a section, which the browser computes with the same outline rule it folds and moves sections with. `scope: "note"` is the only way to say "every claim here", and the **server** enumerates the blocks, so that assertion cannot be a shorter list a client called complete. A named id that is not in the note is 404, never a silent skip — block ids are derived from content, so a stale list would otherwise report success over fewer claims than the person was looking at. **This changes what every future recall returns without changing a byte of any note**: `verificationWeights` scores verified at 1, unverified at 0.85, stale at 0.5 and superseded at 0.2, and a superseded claim leaves the default pack entirely. So it is clamped exactly like a write, and it adds one rule of its own — **only a person may set `verified`**. the provenance vocabulary in `design/DESIGN.md` reads "agent proposed, verified by a person" and has no state for an assistant vouching for itself, so an agent principal is refused for that destination and left free to set `stale`, `superseded` or `unverified`, every one of which makes retrieval trust the claim less. A verification is never queued: there is no diff for a reviewer to accept, so a `suggest` capability is a refusal with its own sentence rather than a third path. `from` reports what these claims held a moment ago, because "from which state" is half of an accountability record and is unrecoverable once the commit lands. **Success:** `200` **Request body** *When `scope` is "claims":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `scope` | `"claims"` | yes | — | | | `permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `block_ids` | string[] | yes | — | The claims to move. One id is a single claim; the run of ids under one heading is a section, which the browser computes with the same outline rule it folds with. | | `to` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | `verified` — a person checked it. `unverified` — withdraw a mark. `stale` — it was true and may not be now. `superseded` — a later claim replaced it, and recall stops returning it. | | `note` | string | no | — | One optional sentence saying why. It is stored on the audit row and nowhere else. | *When `scope` is "note":* | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `scope` | `"note"` | yes | — | | | `permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `to` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | `verified` — a person checked it. `unverified` — withdraw a mark. `stale` — it was true and may not be now. `superseded` — a later claim replaced it, and recall stops returning it. | | `note` | string | no | — | One optional sentence saying why. It is stored on the audit row and nowhere else. | **Response** Changes no byte of any note. It changes `provenance.state`, which is what every future recall ranks with. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `generated_at` | string | yes | — | ISO-8601 UTC. | | `permalink` | string | yes | — | | | `ref` | string | yes | — | `kmem://folder/slug`. | | `to` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `from` | object | yes | — | What these claims held before this call. The 'from which state' half of the accountability record, and unrecoverable afterwards. | | `from.unverified` | integer | yes | — | | | `from.verified` | integer | yes | — | | | `from.stale` | integer | yes | — | | | `from.superseded` | integer | yes | — | | | `blocks_changed` | integer | yes | — | Claims whose provenance row actually moved. | | `blocks_requested` | integer | yes | — | Claims this call named. | | `blocks_in_note` | integer | yes | — | Every block of the note, so a client can say what share of it this touched. | | `blocks_unattributed` | string[] | yes | — | Ids with no provenance row, so nothing was recorded for them. They predate provenance tracking or were written outside the commit path. | | `verify_tasks_closed` | integer | yes | — | Non-zero only for `verified`: marking a claim stale is what opens a task. | | `claims` | object[] | yes | — | Every claim this call named, with the waymark it draws now, so the screen redraws without refetching the note it is editing. | | `claims[].id` | string | yes | — | | | `claims[].ref` | string | yes | — | | | `claims[].waymark` | object | yes | — | Provenance and freshness for one block: who wrote it, through which door, whether anybody has verified it, and how much its age has discounted it. | | `claims[].waymark.author_kind` | `"human"` \| `"agent"` | yes | — | | | `claims[].waymark.author` | string \| null | yes | — | The account id, or an agent identity, exactly as `provenance` recorded it. Null for a person's write served to a reader who is not a signed-in member, such as a published note. | | `claims[].waymark.author_name` | string \| null | yes | — | The person `author` names, by `personDisplayName`. Null for an agent's write, when nobody was recorded, when no account on this instance matches, and for a reader who is not a signed-in member. Show `author` only when it is not an account id. | | `claims[].waymark.agent_client` | string \| null | yes | — | `claude-code`, `cursor`. Null for human writes. | | `claims[].waymark.agent_model` | string \| null | yes | — | | | `claims[].waymark.on_behalf_of_name` | string \| null | yes | — | The person who owns the agent's connection, by name, so a client can print "claude-code for Priya". Null for a human write, when no account matches the recorded owner, and for a reader who is not a signed-in member. Never an account id. | | `claims[].waymark.session_id` | string \| null | yes | — | Groups every write from one sitting. Null for a reader who is not a signed-in member. | | `claims[].waymark.source` | `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` | yes | — | | | `claims[].waymark.created_at` | string | yes | — | ISO-8601 UTC. | | `claims[].waymark.state` | `"unverified"` \| `"verified"` \| `"stale"` \| `"superseded"` | yes | — | | | `claims[].waymark.verified_by` | string \| null | yes | — | The account id of whoever last set `state`. A verifier only while `state` is `verified`: on a stale or superseded claim it names whoever marked it so, which may be an agent's label. Null for a reader who is not a signed-in member. | | `claims[].waymark.verified_by_name` | string \| null | yes | — | The person `verified_by` names. Null on the same three counts as `author_name`. | | `claims[].waymark.verified_at` | string \| null | yes | — | ISO-8601 UTC. | | `claims[].waymark.accepted_by` | string \| null | yes | — | The account id of the person who accepted these words through a review decision, or null. The third trust rung: a person let the words in and did not attest to each line — a different act from `verified_by`, recorded beside it. Null for a reader who is not a signed-in member. | | `claims[].waymark.accepted_by_name` | string \| null | yes | — | The person `accepted_by` names. Null on the same three counts as `author_name`. | | `claims[].waymark.accepted_at` | string \| null | yes | — | ISO-8601 UTC of the acceptance. Both acceptance fields are set, or neither. | | `claims[].waymark.freshness` | number | yes | — | `exp(-age_days / half_life)`, in (0, 1]. The opacity of the waymark's bar. | | `claims[].waymark.half_life_days` | number | yes | — | The half-life this note type decays over (PRD 4.2). | | `claims[].waymark.past_half_life` | boolean | yes | — | True once the claim is older than its half-life. | | `claims[].waymark.description` | string | yes | — | The glyph in words, for the accessible name. Never the sole carrier of meaning. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `block-not-found` | application | `404` | No block exists at that address. | | `transition-refused` | application | `403` | The caller may read this note and may not change it, or an assistant asked for `verified`. Never returned for a note the caller cannot read — that is the 404 above (invariant I7). | | `nothing-to-verify` | application | `409` | The note is live and has no blocks, so there is no claim to mark. | **Example** ```http POST /api/verify ``` ```json { "scope": "claims", "permalink": "backend/auth-decision", "block_ids": [ "blk_7f3a91c2" ], "to": "stale", "note": "The expiry moved to one hour in June." } ``` ```json { "generated_at": "2026-08-26T09:12:44.000Z", "permalink": "backend/auth-decision", "ref": "kmem://backend/auth-decision", "to": "stale", "from": { "unverified": 0, "verified": 1, "stale": 0, "superseded": 0 }, "blocks_changed": 1, "blocks_requested": 1, "blocks_in_note": 6, "blocks_unattributed": [], "verify_tasks_closed": 0, "claims": [ { "id": "blk_7f3a91c2", "ref": "kmem://backend/auth-decision#blk_7f3a91c2", "waymark": { "author_kind": "human", "author": "owjfL22vKdDunQM3yOzmCtEF28ixedyd", "author_name": "Ankur", "agent_client": null, "agent_model": null, "on_behalf_of_name": null, "session_id": null, "source": "web", "created_at": "2026-03-14T10:02:00.000Z", "state": "stale", "verified_by": "owjfL22vKdDunQM3yOzmCtEF28ixedyd", "verified_by_name": "Ankur", "verified_at": "2026-08-26T09:12:44.000Z", "accepted_by": null, "accepted_by_name": null, "accepted_at": null, "freshness": 0.61, "half_life_days": 365, "past_half_life": false, "description": "Written by Ankur. Marked stale: it was true and may not be now." } } ] } ``` --- ### `POST /api/trust/duplicates` Is this already written down? Cosine above 0.92. The same near-duplicate check both agent write doors run before a `remember` lands (PRD phase 7), exposed so an editor can offer it before somebody types a note that already exists. The threshold is 0.92, which is the number PRD 4.1 step 8 uses to collapse two claims inside one pack — one judgement about what "the same thing" means, applied at read time and at write time alike. It is a suggestion and never a refusal: a near-duplicate is sometimes a second independent source. `unavailable` is set, and `duplicates` empty, when the check could not run at all — "nothing is similar" and "nobody looked" are different facts and a caller that conflates them will eventually tell somebody their note is novel because the embedder was rate-limited. Matches are materialised under the caller's own read scope, so a note this session cannot open is never quoted back at it. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `text` | string | yes | — | The text about to be written. Embedded once; nothing is stored. | | `exclude_permalink` | string | no | — | A note whose own chunks are not duplicates of this — set it when editing. | | `threshold` | number | no | `0.92` | Cosine floor. PRD phase 7 sets 0.92 and that is the default. | | `limit` | integer | no | `3` | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `threshold` | number | yes | — | | | `duplicates` | object[] | yes | — | | | `duplicates[].ref` | string | yes | — | `kmem://permalink#blk_xxxxxxxx` — the block a merge would target. | | `duplicates[].permalink` | string | yes | — | | | `duplicates[].similarity` | number | yes | — | Cosine, in [0, 1]. Above `threshold` by construction. | | `duplicates[].excerpt` | string | yes | — | What the existing text says, so the caller can judge. | | `merge_suggestion` | string \| null | yes | — | What to do about it, in words. `null` when nothing crossed the threshold. | | `unavailable` | string \| null | yes | — | Why the check did not run. Distinct from an empty list: "nothing similar exists" and "nobody looked" are different facts. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http POST /api/trust/duplicates ``` ```json { "text": "D1 caps bound parameters at 100 per statement." } ``` ```json { "threshold": 0.92, "duplicates": [ { "ref": "kmem://backend/storage-layer#blk_3f2a91c7", "permalink": "backend/storage-layer", "similarity": 0.9614, "excerpt": "D1 accepts at most 100 bound parameters per statement, so a load of 500 ids is not one statement." } ], "merge_suggestion": "kmem://backend/storage-layer#blk_3f2a91c7 already says close to this (cosine 0.961). Edit that note instead of writing a second one, or write this one and link it with supersedes if it replaces what is there.", "unavailable": null } ``` --- ### `POST /api/export` Download a knowledge pack: one Markdown file, or a zip of notes. The fallback for a client Quick Connect does not work with, and the best single artifact this product can hand somebody cold. The response is the export artifact itself, not a JSON envelope — see "Response" below. `format: "markdown"` inlines every selected note into one file with a table of contents; `format: "zip"` writes one `.md` per note at its own permalink path. Name folders, permalinks, or both; at least one must resolve to something. Selection is checked against the caller's own read scope (`readScopeFor`) — a member exports what a member may read, an owner exports what an owner may read, and there is no separate export permission. An address that does not exist, is soft-deleted, or is outside the caller's scope is not an error: it is listed as unresolved and the rest of the pack still builds. A `POST`, not a `GET`, so a folder list naming something sensitive stays out of browser history and a proxy log — `/api/recall`'s own reason. A kmem token is never accepted here: `requireSession` on the whole `/api` group already refuses one, because a bulk download of everything a scope can read is deliberately not what PRD 5.2's bearer credential hands out. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `folders` | string[] | no | `[]` | | | `permalinks` | string[] | no | `[]` | | | `format` | `"markdown"` \| `"zip"` | yes | — | | **Response** Not a JSON envelope: the response body is the export artifact itself. `format: "markdown"` answers `text/markdown; charset=utf-8` with the whole pack inlined. `format: "zip"` answers `application/zip` with the raw archive bytes; a string cannot show a zip byte for byte, so the example below is the markdown case. Every response carries `Content-Disposition: attachment`, `x-export-note-count`, `x-export-unresolved-count`, and `x-export-truncated`. _No fields._ **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http POST /api/export ``` ```json { "folders": [ "handbook" ], "permalinks": [], "format": "markdown" } ``` ```json "# kmem — knowledge pack\n\n1 note, exported 2026-08-19T09:18:03.000Z.\n\n- [Auth decision](#auth-decision)\n\n---\n\n## Auth decision\n\n> `backend/auth-decision` · decision · updated 2026-03-20T11:04:00.000Z\n\nSession cookies over JWT — chosen for revocation simplicity.\n" ``` --- ### `POST /api/ingest` Open a folder-ingest run and get back what to upload. The first of three requests `npx kmem ingest` makes. The body is a manifest — one path and SHA-256 per file, and no content — and the answer says which of them kmem already holds byte for byte and which it wants. Idempotence is decided here, from `notes.content_sha`, which is why a second ingest of an unchanged folder uploads nothing at all and why `--dry-run` provably writes nothing: a dry run is this request and no others. Content goes to `POST /api/ingest/{run}/files` afterwards, twenty-five files at a time, each batch its own Worker invocation — which is how a thousand-file import finishes. Without `--force` every changed file becomes a proposal, on every instance, whatever `SIGNPOST_WRITE_POLICY` says. **Success:** `200` **Request body** Opens an ingest run. Content is uploaded afterwards, and only for what the plan wants. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | — | Unique per invocation. Names the Durable Object holding this run, so two imports of the same folder proceed independently. | | `mode` | `"review"` \| `"force"` \| `"dry-run"` | yes | — | `review` files proposals, `force` commits, `dry-run` writes nothing. | | `root` | string | no | — | The folder the operator named. For the report. | | `into` | string | no | — | Land the notes under this folder. | | `ignore` | string[] | no | — | `.gitignore` then `.signpostignore`, as text. kmem applies them. | | `files` | object[] | yes | — | One entry per candidate file. No content: this request decides what to send. | | `files[].path` | string | yes | — | Relative to the folder. Reported back verbatim. | | `files[].sha` | string | yes | — | SHA-256 of the file's bytes, lowercase hex. | | `files[].bytes` | integer | yes | — | | **Response** What kmem already has, and what it wants uploaded. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `plan` | object | yes | — | | | `plan.mode` | `"review"` \| `"force"` \| `"dry-run"` | yes | — | | | `plan.files` | object[] | yes | — | | | `plan.files[].path` | string | yes | — | | | `plan.files[].permalink` | string | yes | — | Derived here, with the same rule git sync uses. | | `plan.files[].sha` | string | yes | — | | | `plan.files[].action` | `"skip"` \| `"send"` \| `"refuse"` | yes | — | `skip` is already identical; `refuse` names a file that cannot be a note. | | `plan.files[].because` | string | yes | — | Why, for every file. A report with no reasons is not one. | | `plan.wanted` | string[] | yes | — | Paths to upload, in order. | | `plan.counts` | object | yes | — | | | `plan.counts.total` | integer | yes | — | | | `plan.counts.unchanged` | integer | yes | — | | | `plan.counts.wanted` | integer | yes | — | | | `plan.counts.refused` | integer | yes | — | | | `report` | object | yes | — | The run so far. A dry run is `complete` on this response. | | `report.runId` | string | yes | — | | | `report.mode` | `"review"` \| `"force"` \| `"dry-run"` | yes | — | | | `report.status` | `"planned"` \| `"running"` \| `"complete"` | yes | — | | | `report.results` | object[] | yes | — | | | `report.results[].path` | string | yes | — | | | `report.results[].permalink` | string | yes | — | | | `report.results[].outcome` | `"created"` \| `"updated"` \| `"skipped"` \| `"queued"` \| `"failed"` | yes | — | | | `report.results[].because` | string | yes | — | | | `report.results[].proposalId` | string | no | — | | | `report.wanted` | string[] | yes | — | Paths still to upload. | | `report.counts` | object | yes | — | | | `report.counts.created` | integer | yes | — | | | `report.counts.updated` | integer | yes | — | | | `report.counts.skipped` | integer | yes | — | | | `report.counts.queued` | integer | yes | — | | | `report.counts.failed` | integer | yes | — | | | `report.startedAt` | integer | yes | — | | | `report.finishedAt` | integer \| null | yes | — | | | `report.because` | string | yes | — | PRD §7 step 5, as one sentence. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-run-id` | protocol | `400` | `run_id` is missing or is not 8 to 64 characters of A-Z, a-z, 0-9, hyphen and underscore. Mint one per invocation. | | `invalid-body` | protocol | `400` | The body is not a JSON object. Nothing was written. | **Example** ```http POST /api/ingest ``` ```json { "run_id": "0f0f7d2a-4d1b-4b3a-9a1e-3c9a2b8e1f00", "mode": "review", "root": "/Users/ankur/handbook", "files": [ { "path": "backend/auth.md", "sha": "79ee70e826192c23630b57898f08eeb8a553e0177e8f33b0680286e7d207c8bf", "bytes": 412 } ] } ``` ```json { "plan": { "mode": "review", "files": [ { "path": "backend/auth.md", "permalink": "backend/auth", "sha": "79ee70e826192c23630b57898f08eeb8a553e0177e8f33b0680286e7d207c8bf", "action": "send", "because": "new." } ], "wanted": [ "backend/auth.md" ], "counts": { "total": 1, "unchanged": 0, "wanted": 1, "refused": 0 } }, "report": { "runId": "0f0f7d2a-4d1b-4b3a-9a1e-3c9a2b8e1f00", "mode": "review", "status": "running", "results": [], "wanted": [ "backend/auth.md" ], "counts": { "created": 0, "updated": 0, "skipped": 0, "queued": 0, "failed": 0 }, "startedAt": 1755244800000, "finishedAt": null, "because": "0 created, 0 updated, 0 skipped, 0 queued, 0 failed. 1 file(s) still to upload." } } ``` --- ### `POST /api/ingest/{runId}/files` Upload one batch of content for an open ingest run. The second of the three requests `npx kmem ingest` makes, once per batch of up to twenty-five files the plan asked for. Each batch is its own Worker invocation with its own CPU budget, which is what lets a thousand-file import finish. Sending the same batch twice is safe: every file's `content_sha` was pinned by the plan, and a commit of identical bytes is `unchanged`. One file failing never abandons the rest of the batch — its entry says what went wrong and the others land. A batch against a run that was never opened, or against a dry run, is refused with a `409` that says which; a batch over the byte limit is refused with a `413`. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `runId` | The `run_id` the manifest was opened with. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `files` | object[] | yes | — | | | `files[].path` | string | yes | — | | | `files[].content` | string | yes | — | | **Response** What this batch did, and where the run stands. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `results` | object[] | yes | — | One entry per file in this batch, in the order sent. | | `results[].path` | string | yes | — | | | `results[].permalink` | string | yes | — | | | `results[].outcome` | `"created"` \| `"updated"` \| `"skipped"` \| `"queued"` \| `"failed"` | yes | — | | | `results[].because` | string | yes | — | | | `results[].proposalId` | string | no | — | | | `report` | object | yes | — | The whole run so far, this batch included. | | `report.runId` | string | yes | — | | | `report.mode` | `"review"` \| `"force"` \| `"dry-run"` | yes | — | | | `report.status` | `"planned"` \| `"running"` \| `"complete"` | yes | — | | | `report.results` | object[] | yes | — | | | `report.results[].path` | string | yes | — | | | `report.results[].permalink` | string | yes | — | | | `report.results[].outcome` | `"created"` \| `"updated"` \| `"skipped"` \| `"queued"` \| `"failed"` | yes | — | | | `report.results[].because` | string | yes | — | | | `report.results[].proposalId` | string | no | — | | | `report.wanted` | string[] | yes | — | Paths still to upload. | | `report.counts` | object | yes | — | | | `report.counts.created` | integer | yes | — | | | `report.counts.updated` | integer | yes | — | | | `report.counts.skipped` | integer | yes | — | | | `report.counts.queued` | integer | yes | — | | | `report.counts.failed` | integer | yes | — | | | `report.startedAt` | integer | yes | — | | | `report.finishedAt` | integer \| null | yes | — | | | `report.because` | string | yes | — | PRD §7 step 5, as one sentence. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-run-id` | protocol | `400` | The run id in the path is not 8 to 64 characters of A-Z, a-z, 0-9, hyphen and underscore. Use the id the manifest was opened with. | | `invalid-body` | protocol | `400` | The body is not a JSON object. Nothing was written. | **Example** ```http POST /api/ingest/0f0f7d2a-4d1b-4b3a-9a1e-3c9a2b8e1f00/files ``` ```json { "files": [ { "path": "backend/auth.md", "content": "---\ntitle: Auth decision\n---\n\nSession cookies over JWT.\n" } ] } ``` ```json { "results": [ { "path": "backend/auth.md", "permalink": "backend/auth", "outcome": "queued", "because": "Filed as a proposal for review.", "proposalId": "prop_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f" } ], "report": { "runId": "0f0f7d2a-4d1b-4b3a-9a1e-3c9a2b8e1f00", "mode": "review", "status": "complete", "results": [ { "path": "backend/auth.md", "permalink": "backend/auth", "outcome": "queued", "because": "Filed as a proposal for review.", "proposalId": "prop_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f" } ], "wanted": [], "counts": { "created": 0, "updated": 0, "skipped": 0, "queued": 1, "failed": 0 }, "startedAt": 1755244800000, "finishedAt": 1755244860000, "because": "0 created, 0 updated, 0 skipped, 1 queued, 0 failed." } } ``` --- ### `GET /api/ingest/{runId}` The report of an ingest run, so far. The third of the three requests `npx kmem ingest` makes, and the one a person can repeat: it reads the run's state and writes nothing, so it is safe to poll. `status` is `planned` until the first batch lands, `running` while `wanted` still names paths, and `complete` once nothing is left to upload — a dry run is `complete` as soon as it is planned, because a dry run is the plan and no others. A finished run's report is kept for a while so a resumed CLI and a curious person read the same numbers. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `runId` | The `run_id` the manifest was opened with. | **Response** The run so far. Safe to poll; reading it writes nothing. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `runId` | string | yes | — | | | `mode` | `"review"` \| `"force"` \| `"dry-run"` | yes | — | | | `status` | `"planned"` \| `"running"` \| `"complete"` | yes | — | | | `results` | object[] | yes | — | | | `results[].path` | string | yes | — | | | `results[].permalink` | string | yes | — | | | `results[].outcome` | `"created"` \| `"updated"` \| `"skipped"` \| `"queued"` \| `"failed"` | yes | — | | | `results[].because` | string | yes | — | | | `results[].proposalId` | string | no | — | | | `wanted` | string[] | yes | — | Paths still to upload. | | `counts` | object | yes | — | | | `counts.created` | integer | yes | — | | | `counts.updated` | integer | yes | — | | | `counts.skipped` | integer | yes | — | | | `counts.queued` | integer | yes | — | | | `counts.failed` | integer | yes | — | | | `startedAt` | integer | yes | — | | | `finishedAt` | integer \| null | yes | — | | | `because` | string | yes | — | PRD §7 step 5, as one sentence. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-run-id` | protocol | `400` | The run id in the path is not 8 to 64 characters of A-Z, a-z, 0-9, hyphen and underscore. Use the id the manifest was opened with. | **Example** ```http GET /api/ingest/0f0f7d2a-4d1b-4b3a-9a1e-3c9a2b8e1f00 ``` ```json { "runId": "0f0f7d2a-4d1b-4b3a-9a1e-3c9a2b8e1f00", "mode": "review", "status": "complete", "results": [ { "path": "backend/auth.md", "permalink": "backend/auth", "outcome": "queued", "because": "Filed as a proposal for review.", "proposalId": "prop_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f" } ], "wanted": [], "counts": { "created": 0, "updated": 0, "skipped": 0, "queued": 1, "failed": 0 }, "startedAt": 1755244800000, "finishedAt": 1755244860000, "because": "0 created, 0 updated, 0 skipped, 1 queued, 0 failed." } ``` --- ### `GET /api/ingest` The limits a client sizes its manifest and batches against. No run is read and nothing is written. The CLI fetches this so the batch size is the server's number rather than a constant compiled into an old release — the same reason `GET /api/import` publishes its limits. **Success:** `200` **Response** The limits a client sizes its manifest and batches against. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `max_manifest_files` | integer | yes | — | Most files one manifest may name. | | `max_batch_files` | integer | yes | — | Most files one content batch may carry. | | `detail` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/ingest ``` ```json { "max_manifest_files": 5000, "max_batch_files": 25, "detail": "POST a manifest with a run_id to open a run. The response says which paths to upload; send them to /api/ingest/{run_id}/files in batches." } ``` --- ### `POST /api/import` Upload a Markdown-and-zip archive or an Obsidian vault, and import it as a job. The Import screen's door onto the corpus. The request reads the archive, maps every entry to a note address, stores the bytes and answers `202 { job_id, status_url }`; the ingest queue imports at most 20 files per invocation and updates the job after every file, so the run survives the browser leaving and a refresh resumes from the status URL. It is bounded at 300 files, refused up front rather than partially run. `?mode=sync` is the compatibility path: it imports the whole archive inside the request and answers `200 { report }` with the same `results` rows a job carries as `items`. Both modes map the archive with the same planner and apply each file with the same classifier, so the outcomes below are identical. The archive travels base64-encoded inside the JSON body rather than as a binary upload, because every kmem screen sends through the same `shell/api.ts` client and this is the one door that needs a binary payload; `archive_base64` is checked against the base64-inflated equivalent of the archive size limit before a single byte is decoded. Every file becomes one of five outcomes. `added` writes immediately, verified by the caller who ran the upload, and only ever creates: **an upload never replaces a doc's content on its own.** `duplicate` (byte-identical to the doc at that address, or cosine similarity over 0.92 to something already here) writes nothing. `conflict` queues a proposal and writes nothing — either because a doc already holds the target address and this file differs from it (`reason: would-replace`, an `update` proposal carrying the occupant's `expectedSha`), or because the content disagrees with a doc at another address (`reason: conflict`); both are decided with the ordinary `/api/proposals` accept and reject, and the second also with `POST /api/import/conflicts/{id}/supersede`. When another person applies a queued file, the caller who ran the upload stays its author and its verifier. `refused` is a path that could not become a note address. `failed` is anything else that went wrong on that one file, which never abandons the rest of the run. There is no `updated` outcome and no forcing mode: a re-upload of a changed file is sent to review, never applied silently. Every write is attributed as `authorKind: "human"` under the caller who ran it, verified by that caller, with `source: "ingest"` — the same source folder ingest uses, because a browser upload of an existing export is "existing files, brought in," not a note composed in the editor. Read access is checked the same way importing over `/api/ingest` is: the caller's own read scope decides whether an address that already holds a note looks free or occupied. Every result also carries a `reason` code beside its sentence, so a client renders its own wording rather than matching English; `batch_id` names the whole upload, and every conflict it queued repeats it so a review queue can group one upload's proposals — it is `run_id` on the synchronous report. An entry naming an absolute path or climbing out of the archive with `..` is refused as `unsafe-path` and never read. **Success:** `202` **Query** `?mode=sync` keeps the pre-2026-09-11 behaviour for the clients that have not moved. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `mode` | `"sync"` | no | — | Send `sync` to import the whole archive inside the request and get the old `200 { report }` body, whose `report.results` rows are the `items` of a job. Omit it for the durable job, which is what the Import screen uses. The synchronous body is the report. | **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `format` | string | yes | — | One of markdown-zip, obsidian-vault. | | `into` | string | no | — | Land every note under this folder. | | `archive_base64` | string | yes | — | The archive's bytes, base64-encoded. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | yes | — | The job now importing this archive. | | `status_url` | string | yes | — | Where to read it: `/api/import/jobs/{id}`. Poll it; a refresh resumes from it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `invalid-import-format` | protocol | `400` | `format` is not one of markdown-zip or obsidian-vault. | | `invalid-archive-encoding` | protocol | `400` | `archive_base64` did not decode as standard base64. | | `import-archive-refused` | application | `409` | The archive itself could not be read: too large, too many entries, an entry whose decompressed size disagrees with what the archive's central directory declares in either direction, a compression method other than store or deflate, ZIP64, or a corrupt record. The message names which. Nothing was imported. | **Example** ```http POST /api/import ``` ```json { "format": "markdown-zip", "into": "handbook", "archive_base64": "UEsDBAoAAAAAAI9YQVkAAAAAAAAAAAAAAAAJAAAAYXV0aC5tZFBLBQYAAAAAAQABADcAAAAdAAAAAAA=" } ``` ```json { "job_id": "imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d", "status_url": "/api/import/jobs/imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d" } ``` --- ### `POST /api/import/memory/preview` Propose folders and docs for another AI's memory export. Writes nothing. Step three of "Import memory from another AI". `dump` is the other AI's answer to the export prompt the Import screen shows, with every "continue" answer appended: category headers, and one entry per line as `[YYYY-MM-DD] - text` or `[unknown] - text`. The server reads the entries without a model first, so a paste with no entry, over 200,000 characters, over 500 entries, or with a date that does not exist is refused before anything is spent, with the line it failed on. Then one model call groups the entry ids into docs and writes a title and a short introduction for each. The model sees the folders that hold docs the caller can read, and places a doc in one of them when its subject fits; everything else goes into `into`. The layout follows the content and `guidance`, so a person's memory and a company's memory do not get the same folders. The model never writes an entry: each doc's `entries` carry the pasted lines verbatim, an entry the model left out goes into one more doc (`placement: unplaced`), and an address the grammar refuses is moved into `into` (`placement: moved`). Every doc is `private`. The call is capped at the row's output tokens, two minutes and the model's context window, and is recorded in the spend ledger under `surface: ask` whatever it returned. No plan or budget caps it. The paste is never logged. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `dump` | string | yes | — | The other AI's answer to the export prompt, with every “continue” answer appended. Entries are lines such as `[2026-01-31] - text` or `[unknown] - text` under category headers. | | `source` | string | yes | — | The AI the memory came from, as the person names it: ChatGPT, Claude, Gemini, Grok or another name. Up to 40 letters, digits, spaces and `. & + ' _ -`. It names the default folder and the source every doc records. | | `into` | string | yes | — | The folder new docs go into, for example `imported/chatgpt-memory`. It may exist already. A doc may also land in another folder the caller can read, when its subject fits. | | `guidance` | string | no | `""` | Optional wishes about the layout, for example “group by client”. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `into` | string | yes | — | The target folder, as the address it became. | | `completeness` | `"complete"` \| `"incomplete"` \| `"unstated"` | yes | — | What the other AI said about whether its export is complete. | | `completeness_line` | string \| null | yes | — | The sentence that was read from, verbatim, or `null` when it said nothing. | | `categories` | string[] | yes | — | Every category that holds an entry, in paste order. | | `skipped` | object | yes | — | What the paste held that will not be imported. | | `skipped.count` | integer | yes | — | Lines of prose that were not entries or headers. | | `skipped.lines` | string[] | yes | — | The first twenty of them. | | `docs` | object[] | yes | — | Every entry of the paste is in exactly one doc. | | `docs[].address` | string | yes | — | | | `docs[].title` | string | yes | — | | | `docs[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | yes | — | | | `docs[].intro` | string | yes | — | The model's introduction. The entries follow it in the doc. | | `docs[].placement` | `"target"` \| `"existing"` \| `"moved"` \| `"unplaced"` | yes | — | `target` or `existing` as proposed; `moved` when the proposed address was unusable and the doc moved into the target folder; `unplaced` for the doc that holds every entry the model left out. | | `docs[].visibility` | `"open"` \| `"private"` | yes | — | Always `private` in a preview. | | `docs[].entries` | object[] | yes | — | | | `docs[].entries[].id` | integer | yes | — | | | `docs[].entries[].category` | string | yes | — | | | `docs[].entries[].date` | string | yes | — | `unknown`, `YYYY`, `YYYY-MM` or `YYYY-MM-DD`. | | `docs[].entries[].raw` | string | yes | — | The entry's lines exactly as pasted. The doc holds this text. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `memory-folder-invalid` | application | `409` | `into` is empty, is the top level, or cannot be an address. | | `memory-no-entries` | application | `422` | The paste holds no dated entry. | | `memory-invalid-date` | application | `422` | An entry names a date that does not exist, such as 2026-02-31. The message names its line. | | `memory-too-many-entries` | application | `422` | The paste holds more than 500 entries. | | `memory-too-large` | application | `413` | The layout request would pass the model's context window. | | `memory-model-unavailable` | application | `503` | No model can be built for the `importMemory` place: no Workers AI binding, or an override with no credential. | | `memory-model-failed` | application | `503` | The model call failed or passed its two-minute limit. It is still recorded in the ledger, as an estimate. | | `memory-layout-invalid` | application | `422` | The model's reply stopped at its output cap, is not JSON, or does not have the layout's shape. | **Example** ```http POST /api/import/memory/preview ``` ```json { "dump": "```\n## Instructions\n[unknown] - Keep my exact words.\n## Projects\n[2026-09-01] - I lead the Atlas project.\n```\nCompleteness: complete", "source": "ChatGPT", "into": "imported/chatgpt-memory", "guidance": "One doc per project." } ``` ```json { "into": "imported/chatgpt-memory", "completeness": "complete", "completeness_line": "Completeness: complete", "categories": [ "Instructions", "Projects" ], "skipped": { "count": 0, "lines": [] }, "docs": [ { "address": "imported/chatgpt-memory/working-rules", "title": "Working rules", "type": "convention", "intro": "The rules to follow in every conversation.", "placement": "target", "visibility": "private", "entries": [ { "id": 0, "category": "Instructions", "date": "unknown", "raw": "[unknown] - Keep my exact words." } ] }, { "address": "imported/chatgpt-memory/projects/atlas", "title": "Atlas", "type": "note", "intro": "The Atlas project and my part in it.", "placement": "target", "visibility": "private", "entries": [ { "id": 1, "category": "Projects", "date": "2026-09-01", "raw": "[2026-09-01] - I lead the Atlas project." } ] } ] } ``` --- ### `POST /api/import/memory` Import the memory docs a person reviewed, as an import job. Step four of "Import memory from another AI". `docs` are the docs the preview proposed, as the person edited them: each one's address, type, visibility and Markdown, whose first heading is its title. The server writes each doc's header itself: `private` becomes `visibility: private` with the caller on its list, so the caller and the org's owners can read it; `open` writes no level, so every member can read it unless a folder above it is private; and every doc records `sources: ["Imported from {source} memory"]`. Then the docs are zipped and imported exactly as `POST /api/import` imports an archive: the same job, the same `202 { job_id, status_url }`, the same `GET /api/import/jobs/{id}`, the same five outcomes, every write through the commit service as the caller's own, and a doc that would replace an existing one sent to review rather than written. Nothing is written by the request itself. **Success:** `202` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `source` | string | yes | — | The AI the memory came from, as the person names it: ChatGPT, Claude, Gemini, Grok or another name. Up to 40 letters, digits, spaces and `. & + ' _ -`. It names the default folder and the source every doc records. | | `docs` | object[] | yes | — | | | `docs[].address` | string | yes | — | Where the doc goes, for example `imported/chatgpt-memory/working-rules`. | | `docs[].type` | `"note"` \| `"decision"` \| `"convention"` \| `"state"` \| `"reference"` | no | `"note"` | The doc's type. | | `docs[].visibility` | `"open"` \| `"private"` | no | `"private"` | `private`: owners and the caller can read the doc. `open`: every member can, unless a folder above it is private. | | `docs[].markdown` | string | yes | — | The doc's Markdown, as the person reviewed it. The title is its first heading. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | yes | — | The job now importing this archive. | | `status_url` | string | yes | — | Where to read it: `/api/import/jobs/{id}`. Poll it; a refresh resumes from it. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `memory-address-invalid` | application | `409` | A doc's address is not one the address grammar accepts as written, or names a folder policy file. | | `memory-address-repeated` | application | `409` | Two docs have the same address. | | `memory-private-needs-email` | application | `409` | A doc is `private` and the caller's account has no email address to put on its list. | | `memory-import-too-large` | application | `413` | The docs hold more than 600,000 characters of Markdown together. | | `import-archive-refused` | application | `409` | The docs could not be planned as an import, for example a doc larger than one note can hold. | **Example** ```http POST /api/import/memory ``` ```json { "source": "ChatGPT", "docs": [ { "address": "imported/chatgpt-memory/working-rules", "type": "convention", "visibility": "private", "markdown": "# Working rules\n\nThe rules to follow in every conversation.\n\n[unknown] - Keep my exact words.\n" } ] } ``` ```json { "job_id": "imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d", "status_url": "/api/import/jobs/imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d" } ``` --- ### `GET /api/import/jobs/{id}` One import job: where it is, and what happened to each file. The URL `POST /api/import` answered with. Safe to poll and safe to bookmark: reading it writes nothing, and the Import screen holds no state a refresh could lose. `state` is `queued` until the first slice runs, `running` while files are being imported, and then `done` or `failed`; only the last two are final. `processed` counts the files that have an outcome out of `total`, which includes the entries refused before the job started. `items` grows as files land, in archive order, and carries exactly the rows the synchronous report puts in `results` — same outcomes, same reason codes, same sentences. `error` is set only when `state` is `failed`, and it says the cause and then the fix; a job that failed keeps the counts and the rows of everything it did import, because "eighty files imported and then the archive went missing" is the truth and `failed` on its own is not. Readable by the person who ran the import and by an owner of the organization; anybody else, and any other organization, gets `404` rather than `403`, because the difference between "not yours" and "does not exist" is itself a disclosure. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The `job_id` from `POST /api/import`. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | string | yes | — | | | `state` | `"queued"` \| `"running"` \| `"done"` \| `"failed"` | yes | — | `queued` and `running` are live and worth polling; `done` and `failed` are final. | | `format` | `"markdown-zip"` \| `"obsidian-vault"` | yes | — | | | `total` | integer | yes | — | Every file the archive maps to, refusals included. | | `processed` | integer | yes | — | How many of `total` have an outcome. | | `added` | integer | yes | — | | | `duplicate` | integer | yes | — | | | `conflict` | integer | yes | — | | | `refused` | integer | yes | — | | | `failed` | integer | yes | — | | | `batch_id` | string \| null | yes | — | The batch every proposal this upload queued carries, so a queue can group them. | | `items` | object[] | yes | — | One row per file that has an outcome, in archive order. Empty in a listing. | | `items[].path` | string | yes | — | The path as the archive spelled it. | | `items[].permalink` | string | yes | — | The address this path was mapped to. | | `items[].outcome` | `"added"` \| `"duplicate"` \| `"conflict"` \| `"refused"` \| `"failed"` | yes | — | | | `items[].reason` | `"archive-metadata"` \| `"unsupported-type"` \| `"unsafe-path"` \| `"reserved-name"` \| `"unaddressable"` \| `"address-collision"` \| `"too-large"` \| `"new"` \| `"duplicate-content"` \| `"duplicate-similar"` \| `"would-replace"` \| `"conflict"` \| `"already-in-review"` \| `"unreadable-target"` \| `"error"` | yes | — | Which kind of outcome this is, as a code a client can switch on. `because` is the same fact as one sentence about this one file. | | `items[].because` | string | yes | — | Cause, in one sentence. | | `items[].proposal_id` | string | no | — | The proposal a person decides this file by. Set when `outcome` is `conflict` — the one this run queued, the id to decide with below — and when `reason` is `already-in-review`, where it names the identical proposal already waiting and nothing new was queued. | | `items[].batch_id` | string | no | — | Set with `proposal_id`: the batch that proposal belongs to, so a review queue can group them — this run's id for a `conflict`, and the batch the waiting proposal arrived in for `already-in-review`. | | `error` | object \| null | yes | — | Set only when `state` is `failed`. | | `uploaded_by` | string \| null | yes | — | The display name of the person who ran the import, so a reader who is not that person (an owner) is not shown as its uploader. `null` when no account on this instance matches the id the job recorded. | | `created_at` | integer | yes | — | | | `updated_at` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `import-job-not-found` | application | `404` | No import job of this organization has that id, or the reader is neither the person who ran it nor an owner. One refusal for all three, because telling them apart tells a caller which it is. | **Example** ```http GET /api/import/jobs/imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d ``` ```json { "id": "imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d", "state": "done", "format": "markdown-zip", "total": 2, "processed": 2, "added": 1, "duplicate": 0, "conflict": 1, "refused": 0, "failed": 0, "batch_id": "imp_5f4e3d2c1b0a9f8e7d6c5b4a39281706", "items": [ { "path": "auth.md", "permalink": "handbook/auth", "outcome": "added", "reason": "new", "because": "new." }, { "path": "rate-limits.md", "permalink": "handbook/rate-limits", "outcome": "conflict", "reason": "would-replace", "because": "A doc already holds handbook/rate-limits and this file's content differs from it, so nothing was written. A person decides whether to apply it.", "proposal_id": "prop_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", "batch_id": "imp_5f4e3d2c1b0a9f8e7d6c5b4a39281706" } ], "error": null, "uploaded_by": "Priya Raman", "created_at": 1757577600000, "updated_at": 1757577612000 } ``` --- ### `GET /api/import/jobs` This organization's most recent imports. The last twenty, newest first, scoped to the organization and then to the reader: a person sees their own imports, an owner sees the organization's. Every job carries an empty `items`; a listing of twenty jobs of three hundred files each is six thousand sentences nobody asked for, so the rows live on the one job a person opens. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `jobs` | object[] | yes | — | The last 20 imports of this organization, newest first. Each carries an empty `items`: read one job for its rows. | | `jobs[].id` | string | yes | — | | | `jobs[].state` | `"queued"` \| `"running"` \| `"done"` \| `"failed"` | yes | — | `queued` and `running` are live and worth polling; `done` and `failed` are final. | | `jobs[].format` | `"markdown-zip"` \| `"obsidian-vault"` | yes | — | | | `jobs[].total` | integer | yes | — | Every file the archive maps to, refusals included. | | `jobs[].processed` | integer | yes | — | How many of `total` have an outcome. | | `jobs[].added` | integer | yes | — | | | `jobs[].duplicate` | integer | yes | — | | | `jobs[].conflict` | integer | yes | — | | | `jobs[].refused` | integer | yes | — | | | `jobs[].failed` | integer | yes | — | | | `jobs[].batch_id` | string \| null | yes | — | The batch every proposal this upload queued carries, so a queue can group them. | | `jobs[].items` | object[] | yes | — | One row per file that has an outcome, in archive order. Empty in a listing. | | `jobs[].items[].path` | string | yes | — | The path as the archive spelled it. | | `jobs[].items[].permalink` | string | yes | — | The address this path was mapped to. | | `jobs[].items[].outcome` | `"added"` \| `"duplicate"` \| `"conflict"` \| `"refused"` \| `"failed"` | yes | — | | | `jobs[].items[].reason` | `"archive-metadata"` \| `"unsupported-type"` \| `"unsafe-path"` \| `"reserved-name"` \| `"unaddressable"` \| `"address-collision"` \| `"too-large"` \| `"new"` \| `"duplicate-content"` \| `"duplicate-similar"` \| `"would-replace"` \| `"conflict"` \| `"already-in-review"` \| `"unreadable-target"` \| `"error"` | yes | — | Which kind of outcome this is, as a code a client can switch on. `because` is the same fact as one sentence about this one file. | | `jobs[].items[].because` | string | yes | — | Cause, in one sentence. | | `jobs[].items[].proposal_id` | string | no | — | The proposal a person decides this file by. Set when `outcome` is `conflict` — the one this run queued, the id to decide with below — and when `reason` is `already-in-review`, where it names the identical proposal already waiting and nothing new was queued. | | `jobs[].items[].batch_id` | string | no | — | Set with `proposal_id`: the batch that proposal belongs to, so a review queue can group them — this run's id for a `conflict`, and the batch the waiting proposal arrived in for `already-in-review`. | | `jobs[].error` | object \| null | yes | — | Set only when `state` is `failed`. | | `jobs[].uploaded_by` | string \| null | yes | — | The display name of the person who ran the import, so a reader who is not that person (an owner) is not shown as its uploader. `null` when no account on this instance matches the id the job recorded. | | `jobs[].created_at` | integer | yes | — | | | `jobs[].updated_at` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/import/jobs ``` ```json { "jobs": [ { "id": "imj_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d", "state": "running", "format": "markdown-zip", "total": 45, "processed": 20, "added": 20, "duplicate": 0, "conflict": 0, "refused": 0, "failed": 0, "batch_id": "imp_5f4e3d2c1b0a9f8e7d6c5b4a39281706", "items": [], "error": null, "uploaded_by": "Priya Raman", "created_at": 1757577600000, "updated_at": 1757577609000 } ] } ``` --- ### `GET /api/import` The formats and limits a client should size its upload against. No archive is read. `max_archive_bytes` bounds the raw archive before base64 inflates it roughly 4/3; `max_file_bytes` bounds one entry's decompressed size; `max_files` is the most notes one archive may map to before `POST /api/import` refuses the whole run up front rather than partway through. An upload screen states all three before a person chooses a file, so the numbers it shows are the server's rather than a constant compiled into an old release. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `formats` | `"markdown-zip"` \| `"obsidian-vault"`[] | yes | — | | | `max_archive_bytes` | integer | yes | — | | | `max_file_bytes` | integer | yes | — | The largest file that becomes a doc, in bytes. A larger file is refused as `too-large` before it is read. | | `max_files` | integer | yes | — | | | `detail` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/import ``` ```json { "formats": [ "markdown-zip", "obsidian-vault" ], "max_archive_bytes": 26214400, "max_file_bytes": 4194304, "max_files": 300, "detail": "POST an archive as base64 JSON to run an import. Formats: markdown-zip, obsidian-vault." } ``` --- ### `POST /api/import/conflicts/{id}/supersede` Resolve a queued import conflict by superseding what it disagrees with. The one decision specific to an import conflict; the other two are the ordinary review-queue endpoints, `POST /api/proposals/{id}/accept` ("keep both", the proposal's plain payload unmodified) and `POST /api/proposals/{id}/reject` ("keep existing"). Supersession re-runs the same classification the import itself ran, against the corpus as it stands now rather than trusting what was found at import time, because a proposal can sit in the queue for days and the note it disagreed with may since have been edited or deleted. Finding no conflict this time is not an error — the change commits as a plain write, exactly what "keep both" would have done, and `resolution` says so. The commit carries `supersedes:` naming every note it disagreed with, capped at the same limit every other supersession path shares. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `id` | The proposal id, from `results[].proposal_id` above. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `resolution` | string | yes | — | What this call decided, in one sentence. | | `commit` | object | yes | — | | | `commit.outcome` | `"created"` \| `"updated"` \| `"deleted"` \| `"unchanged"` | yes | — | | | `commit.permalink` | string | yes | — | | | `commit.content_sha` | string | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `proposal-not-found` | application | `404` | No proposal exists at that address. | | `import-supersession-refused` | application | `409` | The proposal was already decided, did not come from an import, its payload is not JSON, or its payload is not an import proposal's shape. The message names which; decide it with accept or reject instead when this is not an import conflict. | | `sha-mismatch` | application | `409` | `expected_sha` is not what is stored now, so somebody else committed first. Nothing was written. | | `invalid-change` | protocol | `400` | The Markdown or the address was refused by the write path before anything was read. | | `index-write-failed` | retryable | `503` | The commit could not be indexed. Retry once; `Retry-After` says when. | | `canonical-publish-failed` | retryable | `503` | The commit landed in D1 and the file in R2 is behind. Committing again republishes it. | **Example** ```http POST /api/import/conflicts/prop_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f/supersede ``` ```json { "resolution": "Superseded handbook/rate-limits.", "commit": { "outcome": "updated", "permalink": "handbook/rate-limits", "content_sha": "9f2a1c7d5e3b48a0d6c9f1e2b3a4d5c6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a" } } ``` --- ### `GET /api/git/status` Whether a repository is connected, and what the last sync did. A GitHub App needs four secrets and a repository name, each set with a different command in a different place, so the failure mode is not "it broke" but an operator who set three of the five and cannot tell which two are missing. This names them. On a connected instance it reports the cursor — the commit the last successful pull agreed on — the last run's outcome, how many notes have a recorded merge base, and any `AGENTS.md` hand edit waiting for a decision. It is behind the session cookie rather than on `/health` because which repository an instance mirrors is not something an anonymous caller should learn. `docs/SYNC.md` §7 is the checklist the `fix` points at. **Success:** `200` **Query** No parameters. _No fields._ **Response** Whether a repository is connected, and what the last sync did. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `connected` | boolean | yes | — | False on an instance with no GitHub App, which is the default. | | `missing` | string[] | no | — | Exactly which environment values are unset. Present only when not connected. | | `because` | string | no | — | | | `fix` | string | no | — | Points at docs/SYNC.md §7, the operator's checklist. | | `slug` | string \| null | no | — | `owner/repo`. | | `branch` | string \| null | no | — | | | `cursor` | string \| null | no | — | The commit the last successful pull agreed on. Null means it has never synced. | | `lastRunAt` | integer \| null | no | — | | | `lastOutcome` | string \| null | no | — | | | `trackedNotes` | integer | no | — | Notes with a recorded merge base. | | `trackedNotesCapped` | boolean | no | — | | | `pendingDrift` | object \| null | no | — | An AGENTS.md hand edit waiting for a person to decide. | | `lastError` | object \| null | no | — | The last run that failed — including a webhook pull, which answered GitHub before it ran — or null once a later run succeeded. | | `pullPending` | object \| null | no | — | A pull that reached its per-run limit and is still reading its range. The cursor moves once it finishes. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/git/status ``` ```json { "connected": false, "missing": [ "SIGNPOST_GITHUB_APP_ID", "SIGNPOST_GITHUB_PRIVATE_KEY", "SIGNPOST_GITHUB_INSTALLATION_ID", "SIGNPOST_GITHUB_WEBHOOK_SECRET", "SIGNPOST_GIT_REPO" ], "because": "Git sync is not connected: SIGNPOST_GITHUB_APP_ID, SIGNPOST_GITHUB_PRIVATE_KEY, SIGNPOST_GITHUB_INSTALLATION_ID, SIGNPOST_GITHUB_WEBHOOK_SECRET, SIGNPOST_GIT_REPO are unset.", "fix": "Create a GitHub App, install it on the repository, then set those with `npx wrangler secret put` (or in wrangler.jsonc for SIGNPOST_GIT_REPO). docs/SYNC.md lists every permission the app needs." } ``` --- ### `POST /api/git/sync` Run a sync now. The button an operator presses when a webhook was missed, and the only way to sync at all on an instance whose repository sits behind a network GitHub cannot reach. Every request goes to the one `GitAgent` for that repository, so two syncs can never run at once. The default op is `sync`, which is pull then push — the only safe order, because a push racing a pull puts the two halves of one note on opposite sides. A pull merges, and anything it will not decide for itself becomes a proposal rather than an overwrite. **Success:** `200` **Request body** Run a sync now. An empty body means sync. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `op` | `"pull"` \| `"push"` \| `"sync"` | no | — | Defaults to `sync`, which is pull then push — the only safe order. | | `reason` | string | no | — | Recorded on the commit message and the log. | **Response** What one run did. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `ok` | boolean | yes | — | | | `pull` | any \| null | yes | — | The pull report, or null when the op was `push`. | | `push` | any \| null | yes | — | The push report, or null when the op was `pull`. | | `projection` | string[] | yes | — | One line per projected file, with what happened. | | `because` | string | yes | — | One sentence covering everything the run did. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `git-not-configured` | protocol | `503` | No repository is connected. The message names every environment value that is unset. | | `invalid-op` | protocol | `400` | `op` is not pull, push or sync. An empty body means sync. | **Example** ```http POST /api/git/sync ``` ```json { "op": "sync" } ``` ```json { "ok": true, "pull": null, "push": null, "projection": [ "AGENTS.md: AGENTS.md is already what kmem renders." ], "because": "pulled 4f2a1c9: 0 created, 1 updated, 0 deleted, 37 unchanged, 0 queued, 2 skipped, 0 failed. pushed: every note already matches the repository." } ``` --- ### `POST /api/reindex` Put pending chunks back on the index queue. The operator's recovery path for a dropped index job, which otherwise waits for the 03:00 UTC sweep. It enqueues; it does not write a row and does not embed — the queue consumer does that afterwards, so `index.chunks_pending` in the response has deliberately not moved yet. One call is bounded and returns a `cursor`, so a large corpus takes many calls rather than one a Worker kills. A chunk that is already embedded is never re-enqueued. This is the one path under `/api` that also accepts the token of a connection with write scope as `Authorization: Bearer sp_...`, because a deployed instance is often operated by a script rather than a browser. **Success:** `200` **Request body** What to put back on the index queue. An empty body sweeps one page of everything. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `scope` | `"pending"` \| `"note"` \| `"chunk"` | no | `"pending"` | `pending` sweeps everything unembedded, a page of notes at a time. `note` and `chunk` narrow it to one address. | | `permalink` | string | no | — | Required when `scope` is `note`. | | `chunk_id` | string | no | — | Required when `scope` is `chunk`. `chk_` and 16 hex characters. | | `cursor` | string \| null | no | `null` | Where to continue a `pending` sweep. Pass back the `cursor` from the previous response, unchanged. Null starts at the beginning. | | `limit` | integer | no | `25` | Notes to re-enqueue in this call, at most 100. One call is bounded so a large corpus takes many calls rather than one that a Worker kills. | **Response** What went back on the index queue. The backlog falls when the consumer runs, not on this response. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `scope` | `"pending"` \| `"note"` \| `"chunk"` | yes | — | What this call selected. | | `notes_enqueued` | integer | yes | — | Notes whose jobs were sent, including any that `enqueued` does not name. | | `chunks_enqueued` | integer | yes | — | Chunks named across every job sent. | | `enqueued` | object[] | yes | — | The notes sent that you may read. A note you may not read is re-enqueued and counted too, and never named. | | `enqueued[].permalink` | string | yes | — | A note address: `folder/slug`, no extension and no leading slash. | | `enqueued[].note_id` | string | yes | — | The internal id. What `cursor` is drawn from. | | `enqueued[].chunks` | integer | yes | — | How many of its chunks the job names. | | `skipped` | object[] | yes | — | Selected and not enqueued, each with a reason: the ones you may read, and any whose row is gone. | | `skipped[].permalink` | string \| null | yes | — | Null when the note row itself is gone, which is the one case with no address. | | `skipped[].note_id` | string | yes | — | | | `skipped[].chunks` | integer | yes | — | | | `skipped[].reason` | string | yes | — | Cause, then what to do about it. | | `cursor` | string \| null | yes | — | Pass this back to continue. Null means the sweep reached the end of the pending set, and is always null for `note` and `chunk`. | | `more` | boolean | yes | — | True when another call could find more work. Not a count, and not a promise. | | `stopped_because` | `"complete"` \| `"limit"` \| `"budget"` | yes | — | `complete` is nothing left; the other two mean call again with the cursor. | | `index` | object | yes | — | | | `index.chunks_pending` | integer \| null | yes | — | Measured after the sends, so it has not moved yet: the consumer runs afterwards. Null when the database did not answer. | | `index.embedding_coverage` | number \| null | yes | — | Embedded over total, in [0, 1]. | | `detail` | string | yes | — | One sentence saying what happened and what to do next. | | `measured_in_ms` | number | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `note-not-found` | application | `404` | No note exists at that address. | | `chunk-not-found` | application | `404` | No chunk has that id. A chunk id is derived from its text, so editing a note retires the ids of every chunk it changed. | | `forbidden` | protocol | `403` | The token of a read-only connection was presented. Re-enqueueing spends embedding capacity, so it needs write scope. | **Example** ```http POST /api/reindex ``` ```json { "scope": "chunk", "chunk_id": "chk_fb118a79768795e2" } ``` ```json { "scope": "chunk", "notes_enqueued": 1, "chunks_enqueued": 1, "enqueued": [ { "permalink": "ops/deploy-target-for-the-first-live-instance", "note_id": "note_f3ec0451d3574fa3b34c0c7d1690d1ca", "chunks": 1 } ], "skipped": [], "cursor": null, "more": false, "stopped_because": "complete", "index": { "chunks_pending": 1, "embedding_coverage": 0.9978260869565218 }, "detail": "1 note and 1 chunk are back on the index queue. The queue consumer embeds them; /health reports the backlog falling within a minute or so, not on this response.", "measured_in_ms": 41 } ``` --- ### `GET /api/webmcp/tools` The seven tools, for a page to declare to a browser agent. WebMCP lets a page declare tools to an AI agent running inside the browser, through `document.modelContext.registerTool` (W3C Web Machine Learning Community Group draft, 14 August 2026; Chrome 149 origin trial). This returns the same seven definitions `tools/list` publishes on `/mcp`, byte for byte, so a browser agent and an MCP client are looking at one tool surface rather than two that drift. `annotations` is WebMCP's pair rather than MCP's four — `untrustedContentHint` is true on every tool, because all seven return note text somebody else wrote. See `docs/WEBMCP.md`. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `tools` | object[] | yes | — | The seven, in PRD 5.1's order. | | `tools[].name` | string | yes | — | `recall`, `read`, `browse`, `remember`, `edit`, `verify` or `link`. | | `tools[].description` | string | yes | — | What it does and when to call it. Carried in the agent's context. | | `tools[].input_schema` | object | yes | — | JSON Schema, draft 2020-12, byte-identical to what `tools/list` publishes on `/mcp`. | | `tools[].annotations` | object | yes | — | WebMCP's two hints. MCP's other three have nowhere to go and are dropped. | | `tools[].annotations.readOnlyHint` | boolean | yes | — | | | `tools[].annotations.untrustedContentHint` | boolean | yes | — | Always true. Every result is other people's words — see webmcpAnnotations. | | `write_policy` | `"open"` \| `"conflicts"` \| `"review"` \| `"locked"` | yes | — | `SIGNPOST_WRITE_POLICY`. What happens to an agent write to an `open` note. On `conflicts`, the shipped default, a new doc lands in Docs and a change to an existing doc waits in Review for a person (`docs/ADR/0017`). | | `agent_writes` | `"off"` \| `"on"` | yes | — | The org's agent writes setting (ADR 0022). It does not change what this browser's agent can do: no person made its connection in write mode, so every change from it waits in Review for a person, with the setting `on` or `off`. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/webmcp/tools ``` ```json { "tools": [ { "name": "recall", "description": "Search this team's knowledge base. Returns ranked claims with provenance, conflicts and gaps, inside a token budget. Call before answering anything about this project. When gaps is non-empty, say so rather than guessing.", "input_schema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, "maxLength": 2000 } }, "required": [ "query" ] }, "annotations": { "readOnlyHint": true, "untrustedContentHint": true } } ], "write_policy": "conflicts", "agent_writes": "off" } ``` --- ### `POST /api/webmcp/call` Run one tool as the browser agent, on the signed-in person's authority. Dispatches to the same seven implementations `/mcp` dispatches to, validated against the same Zod schemas. The credential is the person's session cookie — nothing is pasted anywhere and revoking the agent is signing out — but the caller is an assistant, so the write is recorded `author_kind: agent` with `on_behalf_of` set to the signed-in person, `source: webmcp`, and the write clamp applies with a `suggest` scope: every change is queued for a person rather than applied (ADR 0022). The agent reads what the person reads (ADR 0026), with the person's own access lists. A refusal is `isError: true` inside a 200, exactly as at `/mcp`, so the model reads it and carries on. **Success:** `200` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `name` | string | yes | — | One of the seven tools, by name. | | `arguments` | object | no | `{}` | The tool's input, validated against that tool's own schema. | | `agent` | object | no | `{}` | What the in-page agent calls itself. Unverified. | | `agent.name` | string | no | — | What the in-page agent calls itself. Recorded, never trusted. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `content` | object[] | yes | — | What the model reads. | | `content[].type` | `"text"` | yes | — | | | `content[].text` | string | yes | — | | | `structuredContent` | object | yes | — | The same payload, for a program. | | `isError` | boolean | yes | — | A refusal the model should read and act on. Still a 200 — see mcp/results.ts. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http POST /api/webmcp/call ``` ```json { "name": "recall", "arguments": { "query": "why did we choose session cookies", "budget_tokens": 2000 }, "agent": { "name": "Claude in Chrome" } } ``` ```json { "content": [ { "type": "text", "text": "{\"claims\":[],\"gaps\":[\"Nothing in this knowledge base answers that yet.\"],\"used_tokens\":18}" } ], "structuredContent": { "claims": [], "gaps": [ "Nothing in this knowledge base answers that yet." ], "used_tokens": 18 }, "isError": false } ``` --- ### `GET /api/worklog` An agent session's own account of its work. The operator asked for it: every agent session ships a worklog entry so the insights it produced can be mined later — what it did, why, which notes it touched, when, and the full attribution `provenance` already records (mining it is PRD_PART2 F5/F6 and stays out of scope here). Most recent first. Each entry's `notes` is narrowed to the permalinks the caller's own scope may read (invariant I7); `summary` and `rationale` are the entry's own free text and are not filtered. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `limit` | integer | no | `50` | | | `before` | integer | no | — | Unix epoch milliseconds. Only rows older than this — how a caller pages past the first page. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `entries` | object[] | yes | — | | | `entries[].id` | string | yes | — | | | `entries[].at` | string | yes | — | ISO-8601 UTC. | | `entries[].author_kind` | `"human"` \| `"agent"` | yes | — | | | `entries[].author_id` | string \| null | yes | — | | | `entries[].author_name` | string \| null | yes | — | The person `author_id` names, by `personDisplayName`. Null for an agent's entry, whose `author_id` is free text and is never looked up, and when no account on this instance matches. | | `entries[].agent_client` | string \| null | yes | — | | | `entries[].agent_model` | string \| null | yes | — | | | `entries[].session_id` | string \| null | yes | — | | | `entries[].on_behalf_of` | string \| null | yes | — | The named person accountable for an agent entry. Null for a human one. | | `entries[].on_behalf_of_name` | string \| null | yes | — | For an agent's entry, the person who owns its connection, by name. Null for a person's entry and when no account matches. Never an account id. | | `entries[].summary` | string | yes | — | | | `entries[].rationale` | string \| null | yes | — | | | `entries[].notes` | string[] | yes | — | Permalinks the session touched, narrowed to what this caller may read. | | `measured_in_ms` | number | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http GET /api/worklog?limit=1 ``` ```json { "entries": [ { "id": "wl_7c1e9b0a4d1e4a3f8c2b7e5d9a0f3c22", "at": "2026-08-19T09:18:03.000Z", "author_kind": "agent", "author_id": "claude-code-session-3f2a", "author_name": null, "agent_client": "claude-code", "agent_model": "claude-sonnet-5", "session_id": "sess_9c1d", "on_behalf_of": "usr_7c1e4b2a9d3f", "on_behalf_of_name": "ankur", "summary": "Read the auth decision and the deploy runbook, then drafted a migration note.", "rationale": "Asked to check whether the deploy runbook still matches the auth decision.", "notes": [ "backend/auth-decision", "ops/deploy-runbook" ] } ], "measured_in_ms": 6 } ``` --- ### `POST /api/worklog` Append one worklog entry. Append-only: no update, no delete. `on_behalf_of` is never a request field: for an `agent` entry it is always the signed-in session's own person, the same rule a commit's actor follows, so a client cannot name somebody else as accountable for its agent's work. `author_id` is likewise always the signed-in person for a `human` entry; for an `agent` entry it is the free-text identity the client supplies, naming which process did the work. Logged at `warn` on every append, naming the row's id and its attribution — never the summary or the rationale. **Success:** `201` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `author_kind` | `"human"` \| `"agent"` | yes | — | human \| agent. Same vocabulary as provenance.author_kind. | | `author_id` | string | no | — | A free-text agent identity, for an agent entry. Ignored for a human entry, which is always the signed-in person. | | `agent_client` | string | no | — | The client that ran the session: claude-code, cursor, chatgpt. | | `agent_model` | string | no | — | | | `session_id` | string | no | — | Groups every entry, and every write, from one sitting. | | `summary` | string | yes | — | One or two sentences: what the session did. | | `rationale` | string | no | — | Why. | | `notes` | string[] | no | — | Permalinks the session touched. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `entry` | object | yes | — | | | `entry.id` | string | yes | — | | | `entry.at` | string | yes | — | ISO-8601 UTC. | | `entry.author_kind` | `"human"` \| `"agent"` | yes | — | | | `entry.author_id` | string \| null | yes | — | | | `entry.author_name` | string \| null | yes | — | The person `author_id` names, by `personDisplayName`. Null for an agent's entry, whose `author_id` is free text and is never looked up, and when no account on this instance matches. | | `entry.agent_client` | string \| null | yes | — | | | `entry.agent_model` | string \| null | yes | — | | | `entry.session_id` | string \| null | yes | — | | | `entry.on_behalf_of` | string \| null | yes | — | The named person accountable for an agent entry. Null for a human one. | | `entry.on_behalf_of_name` | string \| null | yes | — | For an agent's entry, the person who owns its connection, by name. Null for a person's entry and when no account matches. Never an account id. | | `entry.summary` | string | yes | — | | | `entry.rationale` | string \| null | yes | — | | | `entry.notes` | string[] | yes | — | Permalinks the session touched, narrowed to what this caller may read. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | **Example** ```http POST /api/worklog ``` ```json { "author_kind": "agent", "author_id": "claude-code-session-3f2a", "agent_client": "claude-code", "agent_model": "claude-sonnet-5", "session_id": "sess_9c1d", "summary": "Read the auth decision and the deploy runbook, then drafted a migration note.", "rationale": "Asked to check whether the deploy runbook still matches the auth decision.", "notes": [ "backend/auth-decision", "ops/deploy-runbook" ] } ``` ```json { "entry": { "id": "wl_7c1e9b0a4d1e4a3f8c2b7e5d9a0f3c22", "at": "2026-08-19T09:18:03.000Z", "author_kind": "agent", "author_id": "claude-code-session-3f2a", "author_name": null, "agent_client": "claude-code", "agent_model": "claude-sonnet-5", "session_id": "sess_9c1d", "on_behalf_of": "usr_7c1e4b2a9d3f", "on_behalf_of_name": "ankur", "summary": "Read the auth decision and the deploy runbook, then drafted a migration note.", "rationale": "Asked to check whether the deploy runbook still matches the auth decision.", "notes": [ "backend/auth-decision", "ops/deploy-runbook" ] } } ``` --- ### `POST /api/admin/session` Start this browser's admin session, with a reason. The one admin route mounted without requireAdminSession — it is how a reason comes to exist for the rest of the session to require. Refused (404) unless the signed-in session's role is superadmin; the reason is written to every audit row this session's requests produce afterwards, including this one. State is per-isolate, not durable: an isolate recycle asks for the reason again on the next request. **Success:** `201` **Request body** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `reason` | string | yes | — | One sentence. Written to every audit row this session's requests produce. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `started` | `true` | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | **Example** ```http POST /api/admin/session ``` ```json { "reason": "Checking why org-acme's spend spiked this week." } ``` ```json { "started": true } ``` --- ### `GET /api/admin` Platform spend, tier and health overview. Screen 1 of the superadmin area. Platform totals for the current month: spend split by usd_source (reported, priced from a token table, or estimated — Vectorize's dollars are always an estimate, design §2.3), calls and the unreported count, organization count by tier state, review queue depth, index health and the worklog tail (last 20 rows, across every organization). Cross-tenant by definition and structurally barred from note content: no function this route calls ever takes a ReadScope. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `month` | string | yes | — | "YYYY-MM" in UTC. | | `spend` | object | yes | — | | | `spend.calls` | integer | yes | — | | | `spend.calls_unreported` | integer | yes | — | | | `spend.usd_micros_reported` | integer | yes | — | | | `spend.usd_micros_table` | integer | yes | — | | | `spend.usd_micros_estimate` | integer | yes | — | | | `organizations` | object | yes | — | | | `organizations.total` | integer | yes | — | | | `organizations.by_tier_state` | object | yes | — | | | `review_queue` | object | yes | — | | | `review_queue.depth` | integer | yes | — | | | `review_queue.oldest_at` | string \| null | yes | — | | | `index_health` | object | yes | — | | | `index_health.chunks` | integer | yes | — | | | `index_health.embedded` | integer | yes | — | | | `index_health.lag` | integer | yes | — | | | `worklog_tail` | object[] | yes | — | | | `worklog_tail[].id` | string | yes | — | | | `worklog_tail[].at` | string | yes | — | | | `worklog_tail[].actor` | string | yes | — | The account id, or an agent's link label, as recorded. | | `worklog_tail[].actor_name` | string \| null | yes | — | The person `actor` names, by `personDisplayName`. Null for an agent row and when no account matches; a client then shows the recorded string unless it is an account id. `./worklog.ts` carries the argument. | | `worklog_tail[].actor_kind` | string | yes | — | | | `worklog_tail[].on_behalf_of_name` | string \| null | yes | — | For an agent row, the person who owns its connection, by name. Null for a person's row and when no account matches. Never an account id. | | `worklog_tail[].source` | string | yes | — | | | `worklog_tail[].action` | string | yes | — | | | `worklog_tail[].target` | string \| null | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | **Example** ```http GET /api/admin ``` ```json { "month": "2026-08", "spend": { "calls": 3, "calls_unreported": 0, "usd_micros_reported": 48, "usd_micros_table": 0, "usd_micros_estimate": 10 }, "organizations": { "total": 1, "by_tier_state": { "inside": 1, "near": 0, "over": 0, "unmetered": 0 } }, "review_queue": { "depth": 0, "oldest_at": null }, "index_health": { "chunks": 459, "embedded": 459, "lag": 0 }, "worklog_tail": [ { "id": "aud_7c1e9b0a4d1e4a3f8c2b7e5d9a0f3c22", "at": "2026-08-19T09:18:03.000Z", "actor": "usr_7c1e4b2a9d3f", "actor_name": "ankur", "actor_kind": "human", "on_behalf_of_name": null, "source": "web", "action": "admin_spend_overview", "target": null } ] } ``` --- ### `GET /api/admin/orgs` The organization directory. Screen 2. One row per organization: name, subdomain, tier state, month-to-date spend and project count. Sorted by spend descending — the expensive org is the one the operator came to find. Reads organization, project and org_routing through tenancy/store.ts's already-exported handle, and org_month_spend for the month's totals; none of it is a ReadScope-gated read. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `organizations` | object[] | yes | — | | | `organizations[].org_id` | string | yes | — | | | `organizations[].name` | string | yes | — | | | `organizations[].subdomain` | string | yes | — | | | `organizations[].tier_state` | `"inside"` \| `"near"` \| `"over"` \| `"unmetered"` | yes | — | | | `organizations[].usd_micros_this_month` | integer | yes | — | | | `organizations[].projects` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | **Example** ```http GET /api/admin/orgs ``` ```json { "organizations": [ { "org_id": "org_9f8e7d6c5b4a3928", "name": "Acme", "subdomain": "acme", "tier_state": "inside", "usd_micros_this_month": 58, "projects": 1 } ] } ``` --- ### `GET /api/admin/orgs/{org}` One organization's spend, tier and queue detail. Screen 3. Four panels: spend (by project, by person, by session — the dual attribution, priced, with the estimate label carried on every Vectorize-derived figure), tier state and line, index health, and the review queue. The by-session panel is bounded to the last 90 days and the last 50 rows, read from the ledger rather than a rollup, because a rollup row per session would be a table of one-row groups. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `org` | The organization id. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `org_id` | string | yes | — | | | `name` | string | yes | — | | | `month` | string | yes | — | | | `tier` | object | yes | — | | | `tier.totalUsdMicros` | integer | yes | — | | | `tier.callsUnreported` | integer | yes | — | | | `tier.state` | `"inside"` \| `"near"` \| `"over"` \| `"unmetered"` | yes | — | | | `tier.lineUsdMicros` | integer | yes | — | | | `spend` | object | yes | — | | | `spend.by_project` | object[] | yes | — | | | `spend.by_project[].project_id` | string | yes | — | | | `spend.by_project[].calls` | integer | yes | — | | | `spend.by_project[].usd_micros_reported` | integer | yes | — | | | `spend.by_project[].usd_micros_table` | integer | yes | — | | | `spend.by_project[].usd_micros_estimate` | integer | yes | — | | | `spend.by_person` | object[] | yes | — | | | `spend.by_person[].principal_kind` | `"human"` \| `"agent"` | yes | — | | | `spend.by_person[].principal_id` | string | yes | — | | | `spend.by_person[].principal_name` | string \| null | yes | — | The person `principal_id` names, by `personDisplayName`. Null for an agent, whose principal is a link label and is never looked up, and when no account matches. | | `spend.by_person[].calls` | integer | yes | — | | | `spend.by_person[].usd_micros_reported` | integer | yes | — | | | `spend.by_person[].usd_micros_table` | integer | yes | — | | | `spend.by_person[].usd_micros_estimate` | integer | yes | — | | | `spend.by_session` | object[] | yes | — | | | `spend.by_session[].session_id` | string | yes | — | | | `spend.by_session[].on_behalf_of` | string | yes | — | | | `spend.by_session[].on_behalf_of_name` | string \| null | yes | — | The person `on_behalf_of` names. Null when it is empty or no account matches. | | `spend.by_session[].surface` | `"ask"` \| `"web"` \| `"mcp"` \| `"webmcp"` \| `"quickconnect"` \| `"git"` \| `"ingest"` \| `"cli"` \| `"sync"` \| `"docs"` \| `"admin"` | yes | — | | | `spend.by_session[].provider` | `"workers-ai"` \| `"openrouter"` \| `"openai"` \| `"anthropic"` \| `"vectorize"` | yes | — | | | `spend.by_session[].operation` | `"embed"` \| `"rerank"` \| `"generate"` \| `"vector-query"` \| `"vector-upsert"` | yes | — | | | `spend.by_session[].at` | string | yes | — | | | `spend.by_session[].usd_micros` | integer | yes | — | | | `spend.by_session[].usd_source` | `"reported"` \| `"table"` \| `"estimate"` \| `"none"` | yes | — | | | `index_health` | object | yes | — | | | `index_health.chunks` | integer | yes | — | | | `index_health.embedded` | integer | yes | — | | | `review_queue` | object | yes | — | | | `review_queue.depth` | integer | yes | — | | | `review_queue.oldest_at` | string \| null | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | | `organization-not-found` | application | `404` | No organization exists at that address. | **Example** ```http GET /api/admin/orgs/org_9f8e7d6c5b4a3928 ``` ```json { "org_id": "org_9f8e7d6c5b4a3928", "name": "Acme", "month": "2026-08", "tier": { "totalUsdMicros": 58, "callsUnreported": 0, "state": "inside", "lineUsdMicros": 10000 }, "spend": { "by_project": [ { "project_id": "proj_default", "calls": 3, "usd_micros_reported": 48, "usd_micros_table": 0, "usd_micros_estimate": 10 } ], "by_person": [ { "principal_kind": "human", "principal_id": "usr_7c1e4b2a9d3f", "principal_name": "ankur", "calls": 3, "usd_micros_reported": 48, "usd_micros_table": 0, "usd_micros_estimate": 10 } ], "by_session": [ { "session_id": "sess_9c1d", "on_behalf_of": "usr_7c1e4b2a9d3f", "on_behalf_of_name": "ankur", "surface": "mcp", "provider": "vectorize", "operation": "vector-query", "at": "2026-08-19T09:18:03.000Z", "usd_micros": 10, "usd_source": "estimate" } ] }, "index_health": { "chunks": 459, "embedded": 459 }, "review_queue": { "depth": 0, "oldest_at": null } } ``` --- ### `DELETE /api/admin/orgs/{org}` Remove an organization whose owner cannot sign in. The operator's door onto the same five steps `DELETE /api/orgs/{orgId}` runs — revoke every connection, reject every pending proposal, tombstone every note through CommitService, then remove the control-plane rows with `deleteOrganizationRows` in one D1 batch — from `services/org-deletion.ts`, which both routes call and neither owns. It exists for the organizations the owner's door cannot reach: an abandoned trial, or the `e2e-` organization a live end-to-end run signs up and whose generated password nobody kept. It asks for no password and no typed address, because an operator has neither; what stands in their place is the superadmin fence, the admin session's written reason, and one `admin_org_deletion` audit row written before the first row is touched. Step 3 runs under the admin area's one system read scope (`test/invariants/read-scope-allowlist.ts` argues it), because the operator holds no member row to read as. Paged and budgeted exactly as the owner's door is: an organization larger than the budget answers 202 with `state: "in-progress"`, and the same request repeated continues from where it stopped. Emits `admin_organization_deleted` at warn with the operator's id, the org id and a count per table. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `org` | The organization id. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `state` | `"deleted"` \| `"in-progress"` | yes | — | `deleted` when the organization's rows are gone and nothing is left to do. `in-progress` when this request spent its budget and work remains — send the same DELETE again, or read `status_url` first. | | `org_id` | string | yes | — | | | `revoked_tokens` | integer | yes | — | Connections revoked by this request. | | `rejected_proposals` | integer | yes | — | Pending proposals decided as rejected by this request. | | `deleted_notes` | integer | yes | — | Notes tombstoned by this request. The R2 blobs behind them stay (invariant I2). | | `remaining` | object | yes | — | What this request did not reach. All three are 0 when `state` is `deleted`. | | `remaining.tokens` | integer | yes | — | | | `remaining.proposals` | integer | yes | — | | | `remaining.notes` | integer | yes | — | | | `status_url` | string | yes | — | Where to read whether the deletion finished, without asking for it again. | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | | `organization-not-found` | application | `404` | No organization exists at that address. | **Example** ```http DELETE /api/admin/orgs/org_9f8e7d6c5b4a3928 ``` ```json { "state": "deleted", "org_id": "org_9f8e7d6c5b4a3928", "revoked_tokens": 1, "rejected_proposals": 0, "deleted_notes": 4, "remaining": { "tokens": 0, "proposals": 0, "notes": 0 }, "status_url": "/api/admin/orgs/org_9f8e7d6c5b4a3928/deletion" } ``` --- ### `GET /api/admin/orgs/{org}/deletion` What an operator's deletion has left to do. The operator's spelling of `GET /api/orgs/{orgId}/deletion`, answering the same three counts from the same function. A separate address rather than a reuse of that one, because that one resolves the organization from the request's host and refuses a caller holding no member row — which is every operator. `deleted` means no `organization` row remains, which is the honest answer to "is it gone yet" and not a 404; `pending` means links, proposals or notes are still standing; `ready` means one more DELETE finishes it. Counts only: no title, no address and no person. **Success:** `200` **Path parameters** | Name | Description | | --- | --- | | `org` | The organization id. | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `state` | `"deleted"` \| `"pending"` \| `"ready"` | yes | — | `deleted` when no organization row remains. `pending` when the row remains and notes, proposals or links are still standing. `ready` when the row remains and nothing else does, so one more DELETE finishes it. These three are facts this endpoint can observe; it cannot say whether a deletion is under way, only what is left. | | `org_id` | string | yes | — | | | `remaining` | object | yes | — | | | `remaining.tokens` | integer | yes | — | | | `remaining.proposals` | integer | yes | — | | | `remaining.notes` | integer | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | **Example** ```http GET /api/admin/orgs/org_9f8e7d6c5b4a3928/deletion ``` ```json { "state": "pending", "org_id": "org_9f8e7d6c5b4a3928", "remaining": { "tokens": 0, "proposals": 0, "notes": 12 } } ``` --- ### `GET /api/admin/worklog` The platform audit view, across every organization. Screen 4: the operator's word for audit_log, rendered most-recent-first across every tenant, filterable by actor_kind, action_prefix and org. ADR 0010's integrity block — entries, oldest, newest, actors, unattributed — sits at the top on every load, restated here rather than read from storage/audit.ts's auditIntegrity, which takes a ReadScope this cross-tenant route is structurally barred from minting. **Success:** `200` **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `integrity` | object | yes | — | | | `integrity.entries` | integer | yes | — | | | `integrity.oldest` | integer \| null | yes | — | | | `integrity.newest` | integer \| null | yes | — | | | `integrity.unattributed` | integer | yes | — | | | `integrity.actors` | integer | yes | — | | | `entries` | object[] | yes | — | | | `entries[].id` | string | yes | — | | | `entries[].at` | string | yes | — | | | `entries[].org_id` | string | yes | — | | | `entries[].actor` | string | yes | — | The account id, or an agent's link label, as recorded. | | `entries[].actor_name` | string \| null | yes | — | The person `actor` names, by `personDisplayName`. Null for an agent row, whose `actor` is a label and is never looked up, and when no account matches; a client then shows the recorded string unless it is an account id. | | `entries[].actor_kind` | string | yes | — | | | `entries[].on_behalf_of_name` | string \| null | yes | — | For an agent row, the person who owns its connection, by name. Null for a person's row and when no account matches. Never an account id. | | `entries[].source` | string | yes | — | | | `entries[].action` | string | yes | — | | | `entries[].target` | string \| null | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | **Example** ```http GET /api/admin/worklog?limit=1 ``` ```json { "integrity": { "entries": 214, "oldest": 1755590283000, "newest": 1755676683000, "unattributed": 0, "actors": 3 }, "entries": [ { "id": "aud_7c1e9b0a4d1e4a3f8c2b7e5d9a0f3c22", "at": "2026-08-19T09:18:03.000Z", "org_id": "org_9f8e7d6c5b4a3928", "actor": "usr_7c1e4b2a9d3f", "actor_name": "ankur", "actor_kind": "human", "on_behalf_of_name": null, "source": "web", "action": "admin_worklog", "target": null } ] } ``` --- ### `GET /api/admin/costs` What each organization costs to run, per day or per month. ADR 0025. One row per organization per period: the billed units the cost meter counted (Workers requests and wall time, D1 rows, R2 operations, Vectorize queries, Workers AI neurons and tokens, Durable Object requests and rows, queue operations, emails, log events), the requests by kind, the organization's size (docs, chunks, stored vectors, members, connections, D1 and R2 bytes), and the dollars at list price by product, in micro-dollars. `group=month` adds each organization's share of the account's real overage and of the $5 base fee, computed over every organization. `format=csv` answers the same columns as CSV. `coverage` is `size_only` for days the nightly rollup could not read Analytics Engine, because the token was not set. Counts and dollars only: no title, no body and no person. **Success:** `200` **Query** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `from` | string | no | — | First day, `YYYY-MM-DD`, UTC. Defaults to 30 days before `to`. | | `to` | string | no | — | Last day, `YYYY-MM-DD`, UTC. Defaults to yesterday. | | `org` | string | no | — | One organization id. Omit for every organization. | | `group` | `"day"` \| `"month"` | no | `"day"` | `day`: one row per organization per day, at list price. `month`: one row per organization per calendar month, with the pro-rata share of the account's overage and base fee. A month period always covers whole months. | | `format` | `"json"` \| `"csv"` | no | `"json"` | | **Response** | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `price_version` | string | yes | — | The date the price table was read from Cloudflare's pricing pages. | | `from` | string | yes | — | | | `to` | string | yes | — | | | `group` | `"day"` \| `"month"` | yes | — | | | `money_unit` | `"usd_micros"` | yes | — | | | `columns` | string[] | yes | — | Every column, in order. The CSV header is this list. | | `rows` | object[] | yes | — | One row per organization per period. `coverage` says whether the metered part exists: `full`, `partial`, or `size_only` when the rollup had no Analytics Engine token for those days. | | `meter` | object | yes | — | | | `meter.stale` | boolean | yes | — | True when no rollup has run for 36 hours: the numbers are old, not zero. | | `meter.last_cost_day` | string \| null | yes | — | The newest day whose metered part was written, or null when none ever was. | | `meter.recent_runs` | object[] | yes | — | | | `meter.recent_runs[].run_id` | string | yes | — | | | `meter.recent_runs[].day` | string | yes | — | | | `meter.recent_runs[].status` | `"ok"` \| `"partial"` \| `"failed"` | yes | — | | | `meter.recent_runs[].cost_status` | `"written"` \| `"skipped"` \| `"failed"` | yes | — | | | `meter.recent_runs[].cost_detail` | string \| null | yes | — | | | `meter.recent_runs[].started_at` | string | yes | — | | | `meter.recent_runs[].orgs_costed` | integer | yes | — | | | `meter.recent_runs[].orgs_sized` | integer | yes | — | | | `meter.recent_runs[].unattributed_share` | number | yes | — | | **Errors** | Code | Kind | Status | When | | --- | --- | --- | --- | | `unauthorized` | protocol | `401` | The request carried no signed-in session, or sign-in is not configured on this instance. | | `invalid-request` | protocol | `400` | The body or query did not validate. The message names every field that failed (S8). | | `internal-error` | protocol | `500` | A bug. The message carries the request id, which is also on `X-Request-Id`. | | `no-such-route` | protocol | `404` | The signed-in session's role is not superadmin. Byte-identical to any unmatched path — the admin area does not exist for this caller. | | `admin-session-required` | protocol | `428` | This superadmin session has not started one yet. POST /api/admin/session with a reason first. | | `forbidden` | protocol | `403` | This browser session already started an admin session, and the role no longer says superadmin — the grant was revoked mid-session. | | `invalid-cost-report-query` | protocol | `422` | A day is not YYYY-MM-DD, from is after to, the period is longer than 400 days, or a parameter is unknown. | **Example** ```http GET /api/admin/costs?from=2026-09-15&to=2026-09-15&org=org_9f8e7d6c5b4a3928 ``` ```json { "price_version": "2026-09-16", "from": "2026-09-15", "to": "2026-09-15", "group": "day", "money_unit": "usd_micros", "columns": [ "org_id", "period", "invocations", "usd_micros_list_total" ], "rows": [ { "org_id": "org_9f8e7d6c5b4a3928", "period": "2026-09-15", "invocations": 1840, "usd_micros_list_total": 2231 } ], "meter": { "stale": false, "last_cost_day": "2026-09-15", "recent_runs": [ { "run_id": "mrr_7c1e9b0a4d1e4a3f8c2b7e5d9a0f3c22", "day": "2026-09-15", "status": "ok", "cost_status": "written", "cost_detail": null, "started_at": "2026-09-16T03:00:04.000Z", "orgs_costed": 12, "orgs_sized": 12, "unattributed_share": 0 } ] } } ``` --- ## Regenerating this file ``` node apps/worker/scripts/generate-api-docs.mjs ``` `apps/worker/test/routes/api/reference.test.ts` renders the same function inside workerd and fails, with a diff, when this file and the schemas disagree — so it cannot drift by a word. That test also parses every example above through its own schema, so nothing here is a request that could not be sent or a response that could not be returned.