Skip to content

Control Plane — Go server and MCP tools

Rationale

Go: static binary, performant on I/O across many files, containerizable. MCP: an abstraction layer that keeps the system agnostic to the agent (Claude Code, Codex, Kiro, OpenCode and Antigravity all speak the same protocol).

A (nearly) stateless server

For each mounted KB, the server keeps only a rebuildable derived index and a parsing cache. Everything else lives in the files. Multiple KBs per instance: each with its own index, lock, and git repo.

Read/write boundary

  • Concepts are read and written through MCP tools. Writes validate parseable frontmatter, required fields, path/layout constraints and strict-map type palettes.
  • if_match protects updates from stale overwrites.
  • Each successful logical write in a git KB produces one commit (D903: there is no setting that turns it off). A concept, map or index write appends its own one-line entry to the root log.md (concept_batch: one summary entry); asset and artifact writes do not. log_append adds a narrative entry.
  • Read-only tools carry Tool.ReadOnly=true; HTTP scopes enforce the read/write boundary.

Commit per logical operation (Step 1 — local commit): every tool that changes KB content, the ones internal/mcpserver/tools.go registers through gitWrap(...), is wrapped by it, which acquires the per-KB mutex, runs the tool, and on success (no application error) calls CommitOp. The tools that own their lock and commit are not wrapped: concept_transfer and artifact_move span two KBs (twoKBMove), and git_conflict_resolve takes the KB lock itself and finalizes merge, commit and push of the conflicts it settles. A failed commit does not turn a successful operation into an error: it is logged to stderr. The commit is made whenever the KB is a git repository, whatever the configuration (D903). concept_batch (D125) is the one write tool that additionally rolls back its own already-written files on a late failure (the log entry or a curated-index entry failing after every file was written) — see concurrency.md §Writer boundary.

Write gate (D350, D907): a page that is both an orphan and missing from its map's curated index is one finding (check orphan, message naming both orphan and index_incomplete, one fix: a link and index_entry in the same call), not two (#1273). in a KB that is a git repository gitWrap judges every concept_* write (not concept_transfer, which commits two KBs itself, D760) and supersede after the handler, the generated-index regeneration and repair-on-write (D349), before the commit. No setting turns it off or lowers it: a write_gate key left in a settings.yaml or a kbs[] entry is reported as removed and realigned away (D907), and write_gate: refused in the refusal below is the gate's name, not a key; a KB that is not a git repository has no HEAD to roll back to and commits nothing (D903), so its writes report findings without a gate. It refuses a finding of any check a write can avoid (the registry's AvoidableAtWrite, docs/data-plane.md §Which checks a write could have avoided); a check no write can avoid (one that ages, or a legacy state) never refuses and is left to the doctor. An observation (docs/data-plane.md §Observations, D947) is no finding: a write that takes a map past 50 pages or a page past 30 KB is neither refused nor told of it. It judges the same findings a write returns. A finding counts as introduced when the concepts the call wrote or removed (a moved one under its old path, its message read with the new ID put back to the old one) did not already have the same (path, check, message) at HEAD, so pre-existing findings never block a repair. A refusal rolls the tree back to HEAD, commits nothing and answers an error: write_gate: refused, nothing was written. This call introduced <n> finding(s) a write can avoid (D907): plus the findings as in findings (with their fix), and how to retry: concept_batch is judged as one result, so a page and the link, index entry or target it needs land in one call, and a new page in a map with a curated index needs index_entry: true on its concept_write, concept_new or creating concept_batch write. A finding the first page of an archive cannot avoid is not raised: the only live page of an archive or of the KB is no orphan (D1099). Only concept files are judged: _map.md, index.md, log.md, assets and artifacts never are. A page git reports as a delete plus an add (too small to survive a rewrite under its rename threshold) counts as the same page moved only when it is the only deleted and added concept with that file name and both have the same frontmatter title and type; an unrelated delete and create in one call is judged as a new page. A caller who cannot see the whole KB is refused all the same but told only what it can see: a finding on a page it cannot see is neither judged nor named, and a whole-graph finding (WholeGraphChecks) or an artifact finding is reported with the check and the visible page only, with no other page's id or count and no fix (its fields come from the whole KB's pages and vocabulary), as on the read side (D243). The findings in a successful write's response (concept_write, concept_new, concept_patch, concept_batch, concept_move, concept_archive, supersede) follow the same rule, as do the target KB's findings of concept_transfer (the target needs only write on the destination slots); concept_move's rewritten lists only pages the caller can see, and rewritten_indexes reaches only a caller who sees the whole KB, an index being a whole-KB resource (index_get). Nothing accepts a finding: no argument waives the gate (D907). The gate skips a call whose working tree was not clean before the handler (one stderr line). Commits written before D907 can carry Accepted-Finding: <check>: <reason> trailers; concept_history (accepted_findings) and changes_since (accepted_findings, up to 5 distinct, newest first) still show them.

Degraded and refused writes say where the commit is (D721): when the commit succeeded but the push failed, the response also reports that it was committed, the commit SHA, the number of unpushed commits (when known) and a hint that the change is safe in the local clone and the push is retried on the next write or sync. These fields are added only when the push failed for a reason other than a conflict (not on a normal pending push, nor when a rebase conflict degraded the KB). A write refused because the pull failed while local commits are unpushed (D311) answers nothing was written: N local commit(s) are not yet pushed and syncing with the remote failed: <error>; fix the cause, then retry.

A write commits only its own changes (D357): under the git lock, after the pull and before the handler, gitWrap commits whatever the working tree already holds as its own commit, subject external changes, authored by the server identity (below), trailer Reason: uncommitted changes found before <tool>. The write's commit (git add -A) therefore holds only the write: an editor's uncommitted work is not filed under kb_repair or the doctor, a call that changes nothing makes no commit, and the write gate (D350), which skips a dirty tree, sees a clean one. It is a no-op on a clean tree, outside a git repository, and while git conflicts are open (the write is refused as before and a conflict marker is never committed); a failure is logged and never fails the write. A KB edited outside MCP shows one extra external changes commit per write that finds it dirty. Agent tool state is never part of it: kb.Init and kb.Open add the state directories agent clients write into their working directory (.omc/, .claude/, .codex/, .kiro/, .opencode/, .crush/, .gemini/, .cursor/, at any depth) to .git/info/exclude, so an agent whose shell runs inside a clone does not version and push them (#757); excluding never untracks what is already committed. The generated instructions never list a dot-directory of data/ as an archive.

Why a write happened (D272): every git-wrapped write tool accepts an optional reason string (incident, source, decision), added to its schema by gitWrap; supersede, concept_archive and conflict_resolve declare their own reason entry and the same value also feeds the commit. The commit subject is unchanged; the reason is stored as a Reason: <text> trailer in the commit body (git log --format='%(trailers:key=Reason,valueonly)'), trimmed and with whitespace and newlines collapsed to single spaces, cut at 500 bytes with …. An empty reason writes no trailer. It is not written to the audit log (free text may carry anything), and reaches log.md only where a tool says so (concept_archive, a loosening map_update); changes_since shows it.

Which agent client wrote it (D718): the same commit carries an Agent: <name> trailer after Reason: (in one paragraph with it), where <name> is the MCP clientInfo name of the caller (handshake or request _meta), name only and no version, whitespace and newlines collapsed to single spaces and cut at 64 bytes on a rune boundary (a newline must never reach the message: it could forge a trailer). No clientInfo, or an empty name, writes no trailer. It is self-declared, so it is an attribution hint, not an identity. The server's own commits (external changes, auto-repair) carry none. changes_since and concept_history show it.

Who wrote it, who committed it (D904, D950): the author and the committer of a write are the writer (a forge with a membership push rule checks both): the Cartographer-Author: Name <email> HTTP header of the request when it is valid, otherwise the identity configured for the KB in the server file (git.identity, git.identities), otherwise <token id> <cartographer@<host>>; the machine name (<host>, reduced to what an email domain accepts) is in the Cartographer-Host trailer. A valid value is one Name <email>, at most 256 bytes, no control character or newline, a non-empty name, a plain local@domain email. A value that is not plain ASCII travels in MCP's encoded form for header values (SEP-2243), =?base64?<the UTF-8, base64>?=, which the server decodes before checking it; Cartographer's own senders encode every value holding anything but letters, digits, inner spaces and . - _ @ < > + ' ( ) ,, since a client config could read the rest as a template (${VAR}, $(cmd), {env:VAR}). An invalid one is treated as absent and the write's response gains one line, {"warning": "Cartographer-Author header ignored (…); this write is authored as …"}. stdio and an auth-less deployment have no header, so a write there is by the KB's configured identity, else the token id (local-admin). Every write also ends its trailer paragraph with Cartographer-Host: <host> then Cartographer-Token: <token id>, appended last by the server, which drops any such line a caller put in the subject, the reason or a trailer, so concept_history's token cannot be forged the way the author line can. Commits that are not a write (the external changes commit, a sync merge, conflict resolution) are by the KB's configured identity, else the server, and a sync that rebases local commits gives each its author back as committer; kb create (a new KB's first commit) and import --commit author as the operator running them, the identity git resolves for the KB's path, else the server.

Read freshness across instances (D93): when a KB has an origin, every tool marked [R] that finds the 30 s freshness window expired starts a fetch + pull-rebase of that KB in the background and answers at once from the local clone (D258): a remote change is visible from the next call on, one call late, and no read waits on the remote. At most one such refresh runs per KB. A pull that moves HEAD reconciles the derived indexes, so a remote concept becomes both readable and searchable. Read-side sync is best-effort: a fetch error is logged; a rebase conflict is registered and its concepts are marked degraded. There is no periodic poller: a KB nobody reads is not fetched. Write tools still fetch and rebase before the write, synchronously (D237).

MCP API

This list is the source of truth for the active tools (do not duplicate counts elsewhere).

A tool that answers with JSON answers with compact JSON (no indentation, D1185): every result is built by jsonResult (internal/mcpserver/protocol.go), and text a tool returns verbatim (a stored index, artifact or asset) is never re-encoded. A list in a result is [] when empty, never null (D1327): search with no hit (the Atlas search relays it), concept_move's and concept_merge's rewritten, blockers and validation_errors (gate_check and the checks that report them) and graph_neighbors' neighbors are built non-nil; a field that is absent when empty (omitempty, as documented per tool) stays absent, and the scalars that are null by contract (kb_status's behind_commits, unpushed_commits, last_push_at, last_sync_in_at) are not lists.

Unknown arguments are refused (#1279, a behaviour change): a tools/call carrying a top-level argument the tool's inputSchema does not declare answers JSON-RPC -32602 (invalid params) naming the unknown key(s), instead of ignoring it; the check runs after the unknown-tool answer and authorization (a caller who may not use the tool gets the denial, never a schema probe) and the refusal is audited as a denial (D1279); a typo such as if_mach can no longer silently drop an optimistic-concurrency check. Only the top level is checked (the free-form objects, such as frontmatter, are the caller's data), on a routed endpoint kb is always accepted, and so are the few arguments a tool accepts without declaring them (Tool.HiddenArguments: map_create's map alias, the removed search arguments mode and use_semantic).

Malformed calls are -32602 (#1303): arguments that is not an object answers invalid params: arguments of tool <name> must be an object, and a name the server does not have answers unknown tool <name> (the unknown call is still audited as unknown_tool; a pre-D288 prefixed name keeps its hint after the name). The routed kb "x": tool not found: <name> (a sibling KB has the tool, the named one does not) stays an isError result on purpose: the tool exists on the connection, so it is a refusal by one KB, not an invalid call (D1303). The Atlas API takes limit only as a positive integer on every route that has one (search, changes, work, history, sources, audit, graph, lint) and answers 400 invalid_request otherwise.

A row is `name(params)` then zero, one or two markers, always [R] before [A], then the description. In params, [x] is optional, x required, a \| b alternatives and a trailing [] an array; kb (routed mount) and, for a git-wrapped tool, reason (D272; concept_transfer and artifact_move own their commits and list it) are not listed. TestToolTableMatchesRegistry and TestToolTableParametersMatchSchemas (D1125) check every row against the registry in both directions: one row per registered tool, the markers, and the parameters against each tool's input schema.

Tools marked [R] have Tool.ReadOnly=true (internal/mcpserver): they never mutate KB content and remain callable with a kb:<name>:r scope. All others require rw. Enforcement and scope format → transport-auth.md §Per-KB scopes.

Beyond that read/write split, every tool is also classified by the resource it addresses — exact concept, collection, source/destination, or whole KB — which is what a fine-grained role is evaluated against (D118). Collection tools filter their results per element before applying any limit; exact-resource tools collapse "forbidden" and "missing" into the same generic not found. A tool missing from that classification is denied outright, so adding a tool without choosing its resource semantics fails closed rather than granting access. The table and the guarantees are in transport-auth.md §Roles and fine-grained permissions. The same [R] tools expose annotations: {"readOnlyHint": true} in tools/list per the MCP spec (D76): a client can use this to auto-approve reads without a manual allowlist, without having to derive the list by hand.

Tools marked [A] (advanced, advancedToolNames in internal/mcpserver/visibility.go) are hidden from tools/list, which always shows the core set (D65, D906): operator maintenance/mutation and provisioning plumbing that would bloat the LLM agent's context without being useful in a normal session. They all remain callable via tools/call by name (CLI client, hooks, operator): an unlisted tool is served, not refused (TestServer_ToolsProfile, e2e scenario 20). A client that calls only what tools/list shows cannot reach an advanced tool: that is a property of the client, not a server setting, and the bundled skills and the lint/review messages that cite one also give the core route.

kb_guide is core for the same reason as D123: a descriptor-bound host can call only what tools/list shows, and it is how such a host reaches the guide and the bundled procedures (D1100).

Agent governance vs. operator maintenance (D123). validate, lint, gate_check, and kb_status are read-only governance diagnostics, not operator maintenance: the documented agent loop (loop.md, use-cases.md) runs them every session, and a descriptor-bound MCP host (one that can only invoke tools tools/list advertises, e.g. Codex) cannot call them by name the way a stdio/TUI agent can. They are therefore part of the core set, unlike commit_gate, conflict_resolve, contradiction_report and the rest of advancedToolNames, which stay operator-only and advanced.

The kb argument on the routed mount (D187, D288). On /mcp/routed, the endpoint agent clients use, the connection's KB set is ?kbs= (absent: every mounted KB), narrowed to the KBs the caller's token reads (D1096: a name it cannot read is unknown; a token reading none gets forbidden: no_kb_access; a token writing none of the set gets the read-only view without write tools, whose write calls are refused read_only_token, and without the bridge's header). ?provisioned=1 (D1097; any other non-empty value is a 400) is how a client that has the managed instructions block says so. initialize.instructions carries the operating guide (and server/discover the same text, cacheScope: private): per KB of the connection's effective set its routing sentence, the maps the caller may see ("Maps: a/, b/.", the tools' own word; "No maps yet." when none), for a writer on a KB with no map and none hidden a short cold-start section (map_create, the reserved name services, services as pages under services/<id>; never in the managed block, #1316) and, once, the client-neutral operational bullets (orientation, writes and refused writes, act on the findings a write returns, the status convention, every write is a commit, {{repo:…}}/{{path:…}} placeholders kept verbatim, conflicts settled with conflicts_list + git_conflict_resolve, the list of served procedures with their "use when" and a last line saying these instructions are the whole guide, #1316); then each KB's instructions.md behind its scope sentence (read bounded: past 64 KiB it is cut at a line boundary with a marker naming the bytes left out, as in the managed block, D1110). It is the full rendering of the pieces the managed block shares (the block itself is the brief one since D1182: routing, maps and skill names, with the bullets once and the curated text left to kb_guide), minus what needs a Cartographer client (kb reauthor, the update notice, the ops skill, no git in the clone), read from disk at each handshake, and never names a KB the caller cannot read. A read-only connection gets only the reading bullets and a sentence saying it is read-only. A connection that sent provisioned=1 gets no guide (?provisioned=1&access=read keeps the bridge's one workspace-bind sentence); the per-KB endpoints and serve --kb always send it. The neutral part has a byte budget pinned by a test (the curated text does not). ?access=read (D1018; any other value is a 400) makes the connection read-only, with or without kbs: tools/list carries only the read tools, initialize says so, a call to a write tool is refused with forbidden: unbound_folder, and every response carries Cartographer-Access: read. The local bridge asks for it in a folder under no workspace binding, and treats a missing header as a server that cannot serve the mode. The server accepts access=read from any client, so a generic client that adds it by hand is read-only and reads the bridge's sentence naming cartographer workspace bind (accepted, D1101). It is a scope filter, not a security boundary (D737). ?access=read-min (D1184) is the same read-only connection narrowed to a minimal listing, for an agent that only reads (a supervisor, a Q&A bot) and pays the schemas on every call: tools/list carries exactly these tools, in this order, search, concept_read, index_get, graph_context, graph_neighbors, concept_list, atlas_overview, artifact_read, kb_guide (readMinTools, all inside readOnlyToolNames; about 4.9 KB for one KB, pinned by a golden file and a byte budget). The listing is narrowed, calling is not: every other read tool (graph_path, lint, kb_status, work_list, artifact_list, asset_read...) stays callable by name, as the advanced tools are; every write tool is refused with forbidden: read_only_profile (its hint names no CLI command), the header is Cartographer-Access: read-min, and initialize carries a one-sentence read-only notice. Routed mount only (/mcp/<name>, /mcp?kb= and stdio do not read access); any other value is a 400 naming both values, so a client pointed at a server that predates the profile fails loudly instead of getting the full list. cartographer connect --access read-min writes the entry (configurator.md §cartographer connect [provider|all]). With two or more KBs in that set every tool below carries one extra property, kb, required, a plain string with no enum (the schema is the same for 2 or 9 KBs; the names are in the KB list of your instructions, D1183): the KB is never inferred, and a call without it, or naming a KB outside the set, is refused with an error naming the allowed KBs (D712: for a KB the server mounts but the connection does not bind, the error says to cartographer client bind, cartographer sync and start a new agent session; for a KB that is not mounted, that a KB created after the server started needs cartographer service restart first). With exactly one KB in the set kb is absent from every schema and the call targets that KB (a kb equal to it is accepted, any other value refused). The advertised set is the union of what the set's KBs register, so a tool one KB gates off is still listed and is refused at dispatch for that KB, with an error naming the tool, the KB and the setting. On the per-KB endpoints (/mcp?kb= and /mcp/<name>, plumbing since D288) no kb argument exists and nothing below changes. A call to a pre-D288 prefixed name (<prefix>__<tool>, D102) whose bare tool exists, on either endpoint, fails with a message naming the replacement — the bare tool, with kb: "<name>" when the prefix is one a served KB's name derives and the connection has a kb argument — and asking for a session restart; a prefixed name is never registered and never listed (D320). See transport-auth.md §One routed topology.

Reading and navigation

Tool Purpose
kb_guide([procedure], [file]) [R] How to work in this KB, for a client whose host gives the model only tools (D1100). Without arguments: the operating guide of this one KB (the text initialize sends to a connection with no managed block, D1097, rendered by the same code: the kb-argument sentence only on a connection of two or more KBs; it is also where a brief KB's own instructions.md and skill descriptions live, since the managed block carries only the skill names, D1182), the procedures list now being part of that text (#1316), opened on a non-provisioned connection by one line saying the same text was already sent in initialize; the text stays whole for every connection kind because it is the only route for a host that drops initialize (D1100), then, only for a principal with whole-KB read, This KB's skills (directory name and description cut at 240 characters; read with artifact_read; a skill artifact_read would refuse is not listed; for any other principal the section is absent and never hinted at). procedure (short name or the full cartographer-kb-* name) returns that bundled skill's body without frontmatter, after a fixed preface (steps that run a command, a script or a subagent need a provisioned client; a tool not in your list is not available; "the operator" is the person you work with) and, for ingest and import, a one-line note; a trailing line lists the procedure's references/ files. procedure + file returns one of those files verbatim (path-confined to references/ of that procedure). cartographer-kb-create, cartographer-kb-doctor and cartographer-ops are not served, nor the pre-D958 names; an unknown name, a file alone or a refused file is an error that lists what is available. Resource class: collection (any read grant; forbidden: no_kb_access otherwise). Always core, provisioned connections included. Writes nothing.
atlas_overview([structure]) [R] Root index + maps/journals (concept count, and expanded concept count if any). structure: true appends a ## Structure section computed on the concepts the caller can see (D244): ### Most central, the top 10 by PageRank, and ### Communities, up to 12 communities of at least 3 concepts, each named after its anchor (the member with the strongest ties inside it, D317), with its size and dominant map. A link written by a line that five or more visible concepts share (a template's boilerplate) weighs 1/N in the partition, so it does not weld unrelated concepts into one community. Without it the response is unchanged.
map_list() [R] Lists maps and journals with metadata (kind, ontology_mode — derived, strict when concept_types is non-empty, else flexible (D907) —, concept_types, expanded concept count, effective stale_after and stale_after_defaulted, D346) and each map's whole _map.md contract, every key omitted when unset: description; required_fields and field_values, each in its one shape keyed by concept type with * for every type (D907), so a client can check a tag against the allowed values before writing (D343; a type's list of allowed values replaces the * one for that type, the server does not merge them); forbidden_fields; templates and default_template (declared templates bind every page to one, D371, D907); open_field, open_statuses, open_markers; for a journal harvest_after as the effective days with harvest_after_defaulted (D347); promote_to, procedure_headings, glossary, work_map, index, value_synonyms, forbidden_title_terms, read_whole; legacy_keys, the removed keys the descriptor still carries, which contract_legacy_key reports and kb_repair drops (D907); and malformed, the {key, reason} entries the server tolerated but could not read. Each entry carries map with the same value as name. This is how an agent learns what a map accepts before writing (the ingest procedure reads it before its first gap, D1318).
index_get([path], [with_hash], [outline], [full]) [R] Reads a folder's index.md (progressive disclosure). By default returns raw Markdown verbatim, byte-for-byte, unchanged for any existing caller. with_hash: true instead returns structured {path, content, content_hash}; content_hash is the if_match index_patch expects (D122). With with_hash the content is always returned, whatever its size: the caller asked for it in order to patch it (D720). Size guard (D185): an index over 60 KB without outline or full → {path, content_hash, outline, content_bytes, note} and no content, the same threshold and shape as concept_read (D78); full: true forces the whole content, outline: true returns the heading outline at any size, and still forces the outline over full and with_hash.
concept_read(id, [section], [outline], [full], [with_content], [match], [context], [rev]) [R] Reads a concept or a single section (bounded). The full response is {id, content_hash, frontmatter, body} — the concept's text appears once, as body (D185); frontmatter is the parsed map (strings, lists as arrays, an empty value as null, a nested block as its raw text), so a caller rewriting it need not parse OKF YAML (D309). frontmatter_raw is sent only when the raw text does not parse (then there is no frontmatter, and it is the only way to see and repair the page); the exact bytes of a page that parses are content under with_content (D1185, frontmatter_raw was unconditional). with_content: true returns content (frontmatter + body, the exact bytes on disk) instead of body, so the concept still travels once (D342); only a caller that will re-write the file verbatim needs it, and it is ignored by the section, outline and size-guard responses, which never carried it. match greps the body lines (case-insensitive substring; empty → error) and answers {id, content_hash, match, matches: [{line, start, text}], matches_omitted?}: line is the 1-based body line of the hit, text the window of context lines (default 0, max 20, clamped) either side starting at line start, windows that touch or overlap merged into one entry; at most 50 entries, then matches_omitted; no hit → matches: []. It is exclusive with outline and with_content, but a section narrows the grep to that one section (D835: grep inside one part of a large page); line and start then count from the first line of the section as returned by a plain section read, and the response echoes section; the section-not-found error is the same as a plain section read. It bypasses the 60 KB size guard (the 50-entry cap bounds the output) and works with rev. Returns the content-hash for if_match. A section not found → error with the list of available headings (capped at 50), no guessing. outline=true returns only the structure ({level, title, bytes} per heading) with no content. Size guard: a body over 60 KB without section or outline → returned as outline plus a note (no content), unless full=true forces the full content (D78). The outline, section, size-guard and match responses also carry the parsed frontmatter (D835), so reading a field of a large page needs no second full read. rev (a 7–40 hex commit SHA, as listed by concept_history; anything else, branch names and HEAD~n included, is rejected as invalid rev) reads the concept as it was at that commit: the file's history is walked for the newest commit that is rev or one of its ancestors, so it works across a rename; a rev before the concept existed, or after its deletion, → not found at rev <sha>. The response is the same shape plus rev; its content_hash is computed on the historical content and is not an if_match (D274).
concept_history(id, [limit]) [R] The commits that touch a concept's paths, newest first: <id>.md and the expanded <id>/index.md (so a concept_expand keeps one history), and, behind a concept_move commit, the history of the path it moved from. A rename is never inferred from similarity (git log --follow paired a similar file and listed another concept's commit, #1281). Returns {id, revisions: [{sha, at, author, subject, agent?, token?, reason?, accepted_findings?, path}], truncated}; agent is the Agent: trailer of D718 (omitted when absent), token the Cartographer-Token: trailer of D904 (omitted when absent), reason is the Reason: trailer of D272, accepted_findings the Accepted-Finding: trailers of D350 that commits before D907 carry (<check>: <reason>, omitted when none), path the file's path in that commit. limit defaults to 20 and is clamped to 1–100; truncated is true when exactly limit entries came back. Visibility and scope as for concept_read: a missing or hidden concept → not found. A KB without git returns empty revisions and note: "no git history". It is the recovery path the errors of a write that landed name (concept_move partial failures, a concept_batch whose rollback failed): concept_read(id, rev=<sha>) then shows the page as it was. Core agent profile (D274).
log_tail([path], [n]) [R] Latest N entries relevant to path. Empty path = the verbatim root log. A non-empty path has no log.md of its own (see log_append): it filters root entries prefixed [<path>], preceded by any entries of a pre-existing <path>/log.md. No entries → a JSON note {"entries": 0, "note": "..."}, never a silent empty string (D78).
changes_since([since], [limit], [links]) [R] Git-history digest since an RFC3339 timestamp or <N>d/<N>h duration (default 7d). Returns each changed concept's newest status (added, modified, deleted, moved), latest timestamp (last_at), the latest content change (last_edit_at, D368: the newest commit that is not the server's own upkeep, an auto-repair commit; omitted when only upkeep touched it), added: true when the file was created in the range even if edited since, authors, the agent clients that wrote it (agents: distinct Agent: trailers, D718, newest first, omitted when none), recent operations (ops) and the reasons recorded by the writes (reasons: up to 5 distinct Reason: trailers, newest first, omitted when none) and accepted_findings (up to 5 distinct Accepted-Finding: trailers of commits before D907, D350, newest first, omitted when none); map/log/artifact changes are counted as other_changes. commits_by_agent ({name: commits} over the window, commits without an Agent: trailer not counted, omitted when empty) is filled only for a caller who sees the whole KB, like commit_count. changes_by_day ({"2006-01-02": {work, upkeep?}}, UTC days, omitted when empty) counts every change of the window to a concept the caller can see, one per concept a commit touched, upkeep the server's own auto-repair commits: the whole window's history, never truncated, where concepts keeps each concept once at its newest change (D1057). Results default to 100 concepts (maximum 500) and report truncated; no commits returns a JSON note rather than an empty string (D94). links: true adds links {added, removed [{source, target, target_missing?}], became_orphan, no_longer_orphan, truncated?}: the link changes of the concept files changed in the range, their links at the parent of the oldest commit against their links now, only between concepts the caller can see; at most 500 edges, and past 1,000 changed concept files links is omitted with a links_note (D250).
graph_neighbors(id, [depth], [direction]) [R] Graph neighbors (used for lint scoping). direction is out (default, links from the page), in (backlinks to it), or both; traversal returns minimum distances and can start from a missing target for backlink inspection. The graph sees both markdown links [text](rel.md) (relative to the file) and wiki-links [[id]]/[[id#section]] (root-relative, D72 WP0). Ask direction: "in" before editing a runbook to see which pages cite it. A neighbour that is a link target but not a concept carries "missing": true (D241); a real one has no such field, and carries bytes, its file size: what reading it costs (D301). Any depth is allowed to every principal: the walk runs on the concepts the caller can see, so a hidden concept is never a result, a transit node or a missing target (D249); a narrowed token asking for an id that does not exist gets not found.
graph_context([query], [ids], [limit]) [R] Ranked context around a question or up to 10 concepts (D242). Seeds are the top 5 search hits for query (glossary-expanded like search), weighted by reciprocal rank, plus each of ids; the ranking is personalized PageRank (restart 0.25) over links in both directions. Returns seeds and results [{id, title, score, hops, bytes, via, snippet}] — bytes is the concept file's size, from the graph cache's stat, so an agent sees a read's cost before paying it (D301) — via is the concept a result was reached through, snippet present only with a query — sorted by score, then hops, then id; seeds are not results. limit defaults to 10 (1–30). A query with no hit and no ids returns an empty list with a note. Ask it instead of chaining search, concept_read and graph_neighbors one hop at a time; search offers the same neighbourhood, seeded from its own hits, as its related block (D781).
link_suggest(id, [limit]) [R] Concepts id probably should link to (D242): those sharing at least two neighbours with it and not linked to it in either direction, scored by resource allocation (Σ 1/degree of each shared neighbour, so evidence through a hub counts less). Deprecated and superseded candidates are left out. A concept with no link in either direction has no neighbour to share, so it gets the concepts the keyword search ranks for its title instead (D317), with score 0 and empty common. Returns method (resource_allocation or text_similarity) and candidates [{id, title, score, common}] with up to 5 shared neighbours each; limit defaults to 5 (1–20). Ask it after creating a concept.
graph_path(source, target, [direction], [max_hops]) [R] [A] The shortest chain of links from source to target (D242), either way (both, default) or as written (out), within max_hops (default 6, 1–12); among equal-length chains, the one first in id order. Returns path [{id, title}] and hops, or an empty path with a note.
concept_list([scope], [limit], [where], [fields], [timestamp_before], [timestamp_after]) [R] Exhaustive inventory: {id, title, type, status} (status omitted when empty) for every concept under the scope prefix (empty = the whole KB, including services/), ordered by id. where is an ANDed list of case-sensitive key=value / key!=value frontmatter predicates (a list matches any element; a missing key matches != only); timestamp bounds are strict RFC3339 or YYYY-MM-DD comparisons. Filters run before limit; filtered responses report examined, and timestamp queries also report skipped timestamps. Ask where: ["tags=istio"] to list everything tagged istio. fields (at most 8 non-empty frontmatter keys, D343) adds a fields object per entry: a string for a scalar, an array of strings for a list, omitted when the key is absent or holds another type. content_hash is accepted in fields as a pseudo-field (D835): it is not a frontmatter key but the concept's hash, returned per entry as content_hash (the same value concept_read gives for if_match), so an agent that lists then patches gets each if_match without a read apiece; it does not count toward the 8-key cap. limit defaults to 500, with truncated/total if exceeded. An exhaustive alternative to index_get's progressive disclosure (D72 WP3).
Tool Purpose
search(query, [scope], [limit], [record_miss], [include_archived], [related]) [R] Keyword search — the only mode (D135): a call passing mode or use_semantic is rejected. limit defaults to 20 hits (D70; D1185 lowered it to 8, D1354 restored it): an explicit limit is honoured, with no clamp on the tool. Keyword matching first requires all query terms; with two or more terms and fewer than 3 allowed full matches it also runs an any-term pass (D343), appends those hits after every full match with partial: true, and sets or_fallback: true and a note ("some terms matched only partially") on the response. Every hit includes title (from the frontmatter) and snippet (an excerpt of ~200 chars around the match; FTS5 uses its native snippet(), otherwise it's extracted in-memory) — avoids a concept_read just to judge a hit's relevance (D70) — and bytes, the concept file's size: what reading it whole costs, so an agent can ask for outline or a section of a large page instead (D301, D779). A hit never names the section that matched (D1354; D1207 had it, and it steered agents to partial reads): on a page whose body (the text after the frontmatter) is 2048 bytes or more (sectionMinBodyBytes) the snippet is the excerpt of the ## section that matched best, and that is the only trace of it. It is resolved after the ranking, for the returned hits only (never for graph_context seeding or the write-time lookups), from the section index (sections_fts, §Search index) or, on the in-memory path, from the same weights over the page body; the query used is the original plus its glossary variants. The ranking, the order and the one-hit-per-page rule are untouched. The snippet is the page's own excerpt when the body is under 2048 bytes, the best section is 80% or more of the body, the page has no ## heading, or the match is only in the title, the frontmatter or the assets. A hit on a page that must be read whole carries read: "whole" (and the page's own snippet): any page of a journal (kind: journal), and any page whose type is listed in its map's read_whole (procedures and runbooks read by section lose their sense, so the KB, not the server, says which types). scope is a literal id prefix on both backends: _ and % match themselves, not the SQL LIKE wildcards they would otherwise be. A scope that no concept the caller can see starts with is refused, not answered with an empty list (D779): the error says that scope is not a KB name and lists the maps and journals the caller can see, bare (ops, which a map-scoped token may pass, rather than ops/), and the call records no miss. A scope that matches concepts but finds no hit is an ordinary empty result. Among near-equal matches, the concept the KB links to most ranks first: score includes a centrality prior of at most +20% (D251, §Search index). When the KB has a glossary.yaml (D276), a query containing a glossary canonical or alias as a whole-word phrase (compared lowercased and diacritic-folded) also runs with that phrase replaced by each other member of its group — forbidden terms never expand — at most 8 queries including the original, one level only; hits are merged by id keeping the best score, and the response carries expanded_to (the variants other than the original; omitted when nothing expanded). A miss is recorded only when every variant returned nothing, and never when the call passes record_miss: false (D319) — for a probe that checks whether a concept exists rather than asks a question; absent or true records as usual. On the FTS5 backend a term under three characters matches nothing alone, so a two-letter alias is reached only through its longer variants. The text of an expanded concept's assets is searchable too, and the hit is the owner concept: there is no per-asset result, and the snippet shows the asset text when only it matched (D277; indexable files in data-plane.md §Assets). Archived concepts (status: archived, D322) are left out by default, before the backend's cut, so the page is still full; include_archived: true returns them with archived: true; when nothing live matches and the argument is absent, the search runs again with them in and answers archived_fallback: true plus a note, so an agent never concludes the KB knows nothing from pages that are only archived (that fallback records no miss); include_archived: false never falls back. Every hit stamps the concept's last-surfaced time, never its last-read time: only concept_read is a read (D778, §Search index). The response carries related (D781) by default (D1185 turned it off, D1354 restored it): up to related concepts (default 5, 0 turns it off, above 10 means 10) that the links around the hits reach, each {id, title, via, bytes} — no snippet, no score — via the concept it was reached through, never a retired one: another live step of equal distance, else the nearest live step on the way, else via is left out. It is graph_context's ranking (personalized PageRank, restart 0.25) seeded from the leading full-match hits, a hit past rank 5 left out of the seeds but also out of the block, so the block never repeats a hit; a partial (or_fallback) hit, an archived hit and the archived fallback seed nothing, and a retired concept (deprecated, superseded, archived) is never offered. The block is computed on the caller's visible graph, so a hidden concept neither appears in it nor changes what it shows, and it is omitted, not empty, when there is nothing to add. It never changes a hit, its order or its score, ignores scope (a link can leave the map) and stamps nothing as surfaced. It adds no link-graph validation: it projects the snapshot the ranking's centrality prior has just validated, and is omitted when that validation failed and a second one does too, rather than built on an older snapshot.
reindex([full]) [A] Runs the same reconciliation every search runs (D245) and reports its counts — indexed, updated, removed — with or without a SQLite index. Search is already fresh without it; it is an explicit check after imports or manual edits. With full: true it rebuilds the whole index from every concept instead, returning status: "rebuilt", concepts_indexed and — only with a SQLite index — sql_upserted. It requires write scope because it writes the server-owned SQLite index (D90, D136).

Writing and ingest

Tool Purpose
map_create(name, title, [kind], [concept_types], [required_fields], [field_values], [forbidden_fields], [templates], [default_template], [read_whole]) Creates a map (kind: map, default) or a journal (kind: journal): a directory with _map.md, index.md, log.md. The optional contract fields are serialized deterministically in its descriptor: a non-empty concept_types makes the map strict, and required_fields and field_values are keyed by concept type, * for every type (D907). The name services (the one constant kb.ReservedMapNames) is reserved for the KB-root service-descriptor namespace and refused for every kind, before anything is written (an application error, no commit); the refusal and the tool description name the reserved set, say that services are pages under services/<id> and suggest service-notes (#1316); this is unrelated to the Service concept type (D269). templates (slugs, lowercase and hyphenated) declares the shapes the map accepts, and every page of the map then follows one (D371, D907); default_template must be one of them (D352). A non-empty concept_types makes the map strict (nothing is refused); the response also reports title, kind and ontology_mode. map is accepted as a hidden alias of name (not in the input schema, D285; both given and different is an error).
map_update(map, [title], [required_fields], [field_values], [forbidden_fields], [value_synonyms], [open_statuses], [open_field], [open_markers], [stale_after], [harvest_after], [concept_types], [templates], [default_template], [promote_to], [procedure_headings], [glossary], [index], [work_map], [forbidden_title_terms], [read_whole]) Changes an existing map's title or lint contract and nothing else in _map.md: title (one non-empty line, D304) also rewrites the H1 that repeats the old title and the title and H1 of the map's index.md, never the folder; only the keys given change, each replaced whole, and an empty list, "" or {} removes one (index: curated too); required_fields, field_values and value_synonyms each replace every key of their kind (D275), the first two keyed by concept type, * for every type (D907). open_field (a flat frontmatter key, "" removes it, D347) names the field that carries a concept's open or closed state, open_statuses then listing its open values. concept_types is a strict map's type list (D322): an empty list removes it and the map is then flexible, the mode being derived from the list (D907). stale_after (days) is the stale_open threshold and harvest_after (days) the age at which a closed journal entry becomes a harvest_candidate: -1 removes the key (back to the default: stale_after 60 journal, 30 work map, none reference map, D346; harvest_after 45), and 0 is refused, because ageing cannot be switched off (D907). forbidden_title_terms configures title_quality (D315). read_whole (concept types; an empty list removes it; also accepted by map_create) marks the pages search flags read: "whole" (D1207): it checks nothing, so adding or dropping an entry needs no reason and is no loosening. templates (slugs; an empty list removes it) and default_template (one of templates; "" removes it) bind the map's pages to templates (docs/data-plane.md §Templates are page schemas); the update is refused when it would leave a default outside the list. A change that loosens the contract (a required field dropped, a field no longer forbidden, a field's allowed values dropped or widened, the type list or the templates dropped or widened, kb.ContractLoosening) is refused without a reason, before anything is written; with one it goes through, the reason is the commit's Reason: trailer, the KB's log.md line names what was loosened and why (the Atlas Activity page shows it), and the response lists it as loosened (D907). A tightening needs no reason. When the update sets templates or default_template, the response may carry hints (informational, at most five, never an error): a declared template whose values for a field are a proper subset of the map's own, which D352 allows by design, with the template_field_value the other values will get. When the request carries field_values and a per-type list replaces a map-wide one for the same field, the response carries a warnings entry naming the type, the field and the map-wide values the per-type list no longer allows (#837; advisory, the write goes through; it shares the key with the title warning). A legacy _archive.md descriptor is refused. Returns the map's title and the contract as read back (the lifecycle, review and title keys too; lists as [] when empty, scalars omitted when unset; stale_after is the effective value with stale_after_defaulted when it is the default, D346). D229. The response repeats kind and ontology_mode at top level (also in contract). A default_template outside templates is refused with a hint to add it to templates in the same call.
map_delete(map, [force]) Deletes a map/journal directory, but only if it holds nothing beyond the map_create scaffold (_map.md, index.md, log.md); if any concept remains, errors listing them — move them out with concept_move first, then retry (D88). force: true also deletes the regular non-Markdown files directly under the map (nothing else reaches them) and returns them as removed_files; concepts, subdirectories and symlinks still refuse (D762). The response also carries the deleted map's title and kind when readable.
concept_expand(id) Promotes a concept to an expanded concept: map/name.md → map/name/index.md, same ConceptID (no backlink rewrite; the concept's own relative markdown links are rebased to the new file, so its outbound links keep their targets, D295; a link to a non-Markdown file that does not exist yet is left as written, so it names the concept's own asset-to-be, D759), from which it can grow with map/name/child satellites. Requires a 2-segment id; errors not_found / already_expanded. concept_collapse is its inverse (D160).
asset_read(concept_id, path, [encoding]) [R] Reads a non-Markdown asset inside an expanded concept. Returns content (text or base64; invalid UTF-8 is always base64), raw-byte sha256, size and executable mode.
asset_list(concept_id) [R] Lists an expanded concept's non-Markdown regular assets with path, size, raw-byte sha256, and executable mode. Hidden files are skipped; a file above the cap is listed with oversized: true (D270).
asset_write(concept_id, path, content, [encoding], [executable], [if_match]) Creates or updates an expanded concept asset. 10 MiB decoded limit (the largest file worth versioning in git, D270); .md, hidden, escaping and symlink paths are rejected. if_match is forbidden on create and mandatory on overwrite (a mismatch is a stale_write naming the file's current sha256, hash only: no commit lookup); omitted executable defaults false on create and preserves mode on overwrite.
asset_delete(concept_id, path, if_match) Deletes an asset with mandatory raw-byte if_match (a call without it is refused as a missing parameter, not as a stale write, D318) — an oversized one included — pruning empty asset-only directories but never the owner directory. A mismatch is a stale_write naming the current sha256.
concept_write(id, frontmatter, body, [if_match], [index_entry]) Creates/updates with validation. A frontmatter value that is a map, or a list holding a map, a list or null, is refused with invalid_frontmatter naming the key (D1313; flatten the keys or put the structure in the body); concept_batch applies the same rule per operation. if_match = expected content-hash; fails with stale_write if changed. A stale_write carries the page's current content_hash and, in a git KB, the newest commit that changed it (short SHA, subject, and its Reason when the commit has one, so a background auto-repair shows as such), then tells the caller to re-read the concept (concept_read) and retry with that hash or merge the edit onto it; the leading stale_write: and the bare cause stay first. Read-only lookups: a failed lookup, or a stale_write: … file not found (an if_match on a concept that does not exist, which has no current hash), keeps the bare text. index_entry: true is creation only (refused on an existing id or with if_match, before anything is written) and has the concept_new rules (D714, D1099): an entry under ## New pages of the map's curated index in the same commit, a note for a generated index or a page the index already links, curated_indexes in the response; so the first page of a map is one call the gate accepts. The keyword indexes need no call: the next search reconciles them with the files (D245). The response adds findings (omitted when empty; the write has already succeeded, D289): the concept's frontmatter-driven lint findings plus the structural ones (D312) lint.ScopedCheck computes without walking the KB — broken_link, duplicate_link, bare_link_list, reciprocal_link_item, unknown_placeholder, forbidden_term, orphan, broken_relation, link_to_retired and index_incomplete. Findings on a neighbour are only what the write introduced (a page still linking an ID that is gone; a retired concept this one links), never its pre-existing ones; at most 200 neighbours per direction are examined (scopedNeighbourCap). The whole-KB structural checks (cut_concept, island, map_misfit, facet_sprawl, missing_value_contract, source_uncited) stay with lint and gate_check. The one exception is map_oversize (D716): a write that creates a top-level concept and takes its map from at most the threshold to above it also carries it, once, on the write that crosses (never on later writes into an already-oversize map, never on updates), and it never fails or gates a write. A write that should leave no finding is a write the agent follows up on before moving on. A frontmatter key named like a tool parameter (lint.ToolParamFields, e.g. if_match) is rejected: frontmatter key "<k>" is a tool parameter, not a field — pass it as a top-level argument; the only write-time block of a conformance check. When the call creates the concept, the response also adds similar (D298; omitted when empty): up to 3 {id, title, score} existing concepts whose title shares at least 60 % of its folded words (stop-words dropped) with the new one, ranked by the search score on the new title — advice, never a refusal. An update returns none. findings also carries repeated_fact (D351, advisory: not a lint check, a review kind the write also reports): for each fact line the write added (present in the new body, absent from the previous one; every line of a created concept) that is already in enough other concepts to reach 3 owners counting this one, it names the other owners and quotes the line. A fact line is exactly the kb_review definition (factLines: 40+ bytes, headings, code, table header and separator rows, link-only items and template lines excluded). The owners are found through the search index (the caller's visibility applies, so a hidden owner is neither named nor counted) and each candidate is re-read and kept only if it holds the identical line, since a full-text hit is a candidate, not a proof. A call spends at most 5 index lookups (shared across a concept_batch; lines past the budget are not examined) and gets at most 5 such findings per concept; an error anywhere yields no finding. It needs the previous body and the index, so it is not part of lint.ScopedCheck and gate_check with changed_ids does not report it.
concept_new(template, id, [vars], [index_entry]) Creates a new concept from templates/<slug>.md, with literal single-pass {{identifier}} substitution in frontmatter values and body. When the template's sections come from a fenced markdown sample (guidance prose followed by a skeleton), the page body is that sample's content and the prose is not copied (D830; the rule is the one that reads the template's sections, data-plane.md §Templates are page schemas); a template without such a fence renders its body as written. {{timestamp}} defaults to today's date (YYYY-MM-DD, UTC) when vars omits it; a passed value wins, no other variable has a default (D830). A vars value is a string or a list of strings (D715): a list fills a frontmatter value that is exactly {{name}} (the key becomes a list) or is spliced in place of a list item that is exactly {{name}}; an empty list renders key: [] or drops the item; a list used anywhere else (inside a larger string, a list item with other text, the body) is an error and nothing is written. Refuses existing IDs and missing/extra variables (the variables of a template are those of its frontmatter and the body it renders); makes one commit. index_entry: true also appends - [title](rel.md) under ## New pages in the map's curated index (D714): any non-generated index; a generated index only yields a note, a top-level id or an unreadable index is refused before anything is written, a concept the index already links gets no second entry, and the response carries curated_indexes notes. Without it only the concept is created. In a strict map it is refused, before anything is written, when the template's type is not among the map's concept_types (the strict-type rule, D1314). Records the template's slug as the page's shape and strips the template's x-template.* schema keys (D352); a template outside the map's templates is not refused, the findings carry template_not_allowed. Returns findings (structural ones too, D312) and similar (D298) like concept_write on a creation, and refuses a template whose frontmatter has a tool-parameter key (D289). The response also carries the repeated_fact findings of concept_write (D351). Reports map_oversize on the creation that crosses the map's threshold, like concept_write (D716).
concept_patch(id, [old_string], [new_string], [replace_all], [edits], if_match, [frontmatter], [frontmatter_append], [frontmatter_remove], [unset]) A frontmatter value that is a map, or a list holding a map, a list or null, is refused with invalid_frontmatter naming the key (D1313; flatten the keys or put the structure in the body); concept_batch applies the same rule per operation. String-replace patch on the body only (Edit-like semantics), without rewriting the entire concept. if_match is mandatory: fails with stale_write if changed. A stale_write carries the page's current content_hash and, in a git KB, the newest commit that changed it (short SHA, subject, and its Reason when the commit has one, so a background auto-repair shows as such), then tells the caller to re-read the concept (concept_read) and retry with that hash or merge the edit onto it; the leading stale_write: and the bare cause stay first. Read-only lookups: a failed lookup, or a stale_write: … file not found (an if_match on a concept that does not exist, which has no current hash), keeps the bare text. Fails with old_string_not_found or old_string_ambiguous (use replace_all for multiple matches). frontmatter, if present, is shallow-merged onto the existing frontmatter, and may be the only change — no body edit, with edits absent or empty, e.g. to set a missing title (#321); a key set to null is removed rather than set to a literal null (fails if the key is required, e.g. type, D88). frontmatter_append / frontmatter_remove ({key: [items]}, a bare string is a one-item list) add or drop list items server side, on the list read from disk inside the write, never on what the caller last saw (D342): a frontmatter list replaces the stored one, so a read-modify-write of provenance from a stale copy loses what other sessions added and if_match cannot see it. Both are idempotent (append skips present items, remove ignores absent ones and deletes the key when the list ends up empty), turn a scalar value into a one-item list, refuse a nested block (<key> is not a list) and type, and append also refuses a tool-parameter key. Order: frontmatter, append, remove, unset; each may be the only change. A frontmatter merge that leaves a list without items it had (other than through null, unset or frontmatter_remove) still succeeds but answers a write-time finding list_items_dropped (no fix, not a lint check; concept_write is outside it, replacing the page by contract). unset: ["key", …] removes keys the same way, for clients that strip null values before sending; it applies after the merge, so it wins over a same-key frontmatter entry, may be the only change, and refuses type (D309). Same write path (indices, commit) as concept_write (D70); it returns findings like concept_write (D312; always present, [] when clean, as for concept_write and concept_new, D342), edit_matches: [n, …] (matches per edit, edits form only; replacements stays the total) and rejects a non-null tool-parameter key in frontmatter (a null removes it, which is the repair; D289). As an alternative to the old_string/new_string/replace_all triple, it accepts an edits: [{old_string, new_string, replace_all?}] field to apply several patches in a single call (one commit): the two forms are mutually exclusive; edits are applied in order, atomically (edit i+1 sees the result of edit i) — if one fails, nothing is written and the error reports the index of the failed edit (D76). old_string_not_found adds ; closest line N: "…" when a body line is within edit distance max(3, 20 %) of the first non-empty line of old_string (case and typographic quotes ignored, lines over 500 runes skipped), and old_string_ambiguous gives the true match count, up to 10 matching lines (first 10 lines when the list is cut), the usual cause when old_string starts or ends with a newline (the newline also matches the line break of every neighbouring occurrence: drop it) and the way out — extend old_string with the unique text on the line before or after, or replace_all=true if every occurrence is meant; there is no occurrence parameter. In the edits form a failed edit i > 1 says it was matched against the body left by edits 1..i-1, not the stored page, and that none of them was written (edit 1 and the single form see the stored body and say nothing); concept_batch and index_patch share all of it (D342). frontmatter_remove given a key instead of list items (null, an empty string, an object, a number, a boolean) fails with invalid params: frontmatter_remove: <key> … and shows the right shape: unset: ["<key>"] or frontmatter: {"<key>": null}; frontmatter_append errors are unchanged. The response also carries the repeated_fact findings of concept_write (D351).
index_patch([path], [old_string], [new_string], [replace_all], [edits], if_match) The same bounded Edit-like patch as concept_patch, applied to the root or a Map/Journal's curated index.md instead of a concept (D122). Accepts only the root (path empty) or an existing Map/Journal's index — never an arbitrary nested path, never a reserved file other than index.md; a two-segment path naming an expanded concept's own index (e.g. map/concept) is rejected with expanded_index, pointing at concept_patch(id=<owner>) instead, since that index is a concept, not a curated collection index. if_match is mandatory (read it with index_get(with_hash: true) first): fails with stale_write if changed, naming the index's current content_hash (no commit lookup) so the retry needs no second read. Same old_string/edits batch semantics as concept_patch, one commit per call, one root log.md entry. Never touches the live/SQLite concept search indexes — root/Map indexes are curated prose, not indexed concepts. In a map with index: generated (D301) an edit that changes the block between the cartographer:index markers is refused with generated_index: the server maintains it; text outside the block is free. The response adds findings (D312): an index_incomplete for each existing concept the patch took out of a map whose contract requires index entries.
log_append(entry, [path]) Appends an entry to the root log (never a per-directory log). With path, the entry is prefixed [<path>] and still written to root; log_tail(path) retrieves it by filtering on that prefix (D78).
snapshot([message]) Records an entry in log.md. In a git repository it also creates a git commit of the entire KB.
supersede(source_id, target_id, [reason]) Marks a concept as superseded by another: sets status: superseded and superseded_by. The successor must be an existing concept other than the source (supersede: target not found: <id>, supersede: a concept cannot supersede itself); superseded_by is a relation lint checks, not a graph edge (D243). The text answer ends with a findings: JSON block when the superseded concept has any (D312), typically link_to_retired for the live concepts still linking it.
concept_move(source_id, target_id \| moves[], [rewrite_links]) Moves one or more concepts (batch moves: [{source_id, target_id}], or the single source_id/target_id form; the two forms cannot be mixed). Validates the entire batch before applying any move (application-level atomicity), one git commit per call. With rewrite_links (default true) it rewrites backlinks — wiki-links [[old]]/[[old#section]] and relative markdown links, never those inside code spans — in every concept the link graph says links to a moved id, reading only those (D248), using the old→new map; the result lists the applied moves and rewritten concepts. The same pass rewrites a superseded_by that names a moved concept (D243), and the links to a moved concept in the root and every map's index.md, which are not concepts (rewritten_indexes in the result; concept_merge does the same for the merged satellite): pointing a link at the new location adds or removes no entry, so unlike curated-entry maintenance (D160) it needs no opt-in. With rewrite_links=false backlinks and relations are left intact, which in a git KB means the move is refused by the write gate (D907) whenever the page has any linker (its links would break); the mode is only reachable for a page nothing links to, or in a KB without git (warning in the result, use lint). Both ends are resolved in their own namespace — a map under data/ or the KB-root services/ — so a move works in any direction between them: on success the old ID is not found and the new one is readable, with no copy left behind (D269). An occupied destination (a concept, an unparsable file, or an <id>/ directory holding only assets) is a conflict found before anything moves, and so is a move into another map whose strict concept_types do not admit the concept's type (#921; concept_archive and kb_repair moves are not judged). A filesystem failure after a target was written (source removal, or an expanded directory's rename) is an application error naming both IDs: no commit, no success log entry, no rollback; since the tree was touched, that error ends with the recovery sentence to inspect or undo: concept_history(id) lists the commits, concept_read(id, rev=<sha>) shows the page as it was; restore it with concept_write using the current content_hash as if_match. A write-gate refusal (D907) writes nothing, so it carries no such sentence. D72 WP1/WP2, D248, D269. The response adds findings (D312) for every moved ID and, as a gone ID, for every old one: a link_to_retired on a moved retired concept, a broken_link on a page still linking an old ID (rewrite_links: false, or a link the rewrite could not follow), an index_incomplete in the target map. It also adds mentions (D1070): [{id, path, line, kind}], where the old ID id still appears as a plain whole token that the link rewrite does not own, in the file at path (the new path for a moved concept) at the 1-based line (frontmatter included); kind is artifact (skills/**, agents/*.md, instructions.md), frontmatter (a provenance entry or any other string between the --- fences) or prose (a concept body, code spans and fences included). The characters before and after must not be [A-Za-z0-9_-] and the one before not / (so dev/ops/net does not mention ops/net); a following . or .md counts. Targets of markdown links and wiki-links are excluded (the rewrite owns them; a link it could not follow is a broken_link finding), as are log.md, index.md and _map.md. Sorted by path, line, id, at most 50, the rest counted in mentions_omitted; both keys are omitted when empty. Concept mentions reach only pages the caller sees, artifact ones only a whole-KB caller. Nothing is rewritten: a current page usually wants the new ID, a journal entry may keep the old one on purpose. kb_repair's move does not scan.
concept_archive(id, if_match, [to], [status], [reason]) [A] Retires one concept in one commit (D344). Sets status (deprecated default, or archived), moves it to <to>/<last segment of id> (to: one existing map or journal, default archive) and rewrites inbound links KB-wide, with the shared concept_move core (own links rebased, backlinks and index links rewritten, one commit). if_match is the source's content hash (stale_write: on mismatch, nothing written). It removes the entry from the source map's curated index; a generated index is left to the server and a line citing several concepts is kept and reported in curated_indexes. The destination index gets an entry only if that map opted in. reason goes to log.md on one line (line breaks collapse to a space: the Atlas reads it back from the entry's title line, D820) and to the commit's Reason: trailer. An occupied target is a conflict: (use concept_move to rename instead). A superseded source is refused (D883: relocate it with concept_move). Needs a whole-KB write grant. Returns the concept_move result (mentions included) plus status, with findings (D312): a link_to_retired for live pages that keep a link is the intended signal; mentions / mentions_omitted as in concept_move.
concept_transfer(target_kb, moves[], [reason]) [A] Moves concepts, with their assets, from this KB to another KB mounted on the same server (D760). moves: [{source_id, target_id}] (the target ID is in target_kb); the whole batch is validated first (sources exist, target slots free, expanded concepts need two-segment IDs, no symlink inside a moved tree, no asset over the size cap). It needs a server mounting both KBs (single-KB stdio answers so), both git-backed, and refuses target_kb equal to the source, unknown, or not bound to the routed connection, and an expanded concept holding a file the source's or the target's .gitignore excludes (it would miss the target commit and be deleted from the source), and a move into a map of the target whose strict concept_types do not admit the concept's type (#940; the page would fail the target's next validate). Files are copied byte for byte (an encrypted file is not re-encrypted), the moved pages' own relative links are rebased, a target map's curated index gets its entry. In the source the files are removed and every link to a moved page (markdown or wiki-link, never in code spans, in concepts and in index.md files) becomes its label as plain text — there is no cross-KB link syntax; a superseded_by naming a moved page is cleared. One commit per KB, Moved-To: <target_kb> on the source's and Moved-From: <source kb> on the target's (read back by concept_history as moved_to/moved_from and by changes_since as transfers), then both are pushed. Needs a whole-KB write grant on the source and a write grant on each destination slot of the target. Never refuses for: placeholder keys the target's paths.yaml does not declare (undeclared_placeholders), sops-encrypted files and secret_refs/secrets_source that do not travel (sops_warnings), links from the moved pages to pages that stay behind (links_to_source). The result is {source:{kb,commit}, target:{kb,commit}, moved, unlinked_in, superseded_by_cleared, undeclared_placeholders, sops_warnings, links_to_source, findings_by_kb:{<kb>:[...]}} — the write-findings hook (D353) reads a top-level findings list, so this tool does not use that key; it has no write gate. Skills and other artifacts move with artifact_move (D782).
artifact_move(target_kb, paths[], [reason]) [A] Moves artifacts from this KB to another KB mounted on the same server, on the transaction of concept_transfer (D782). paths (1 to 20) name the artifact and not a file inside it: skills/<slug> and hooks/<name> move as a directory, agents/<slug>.md, mcp/<slug>.json and templates/<slug>.md as one file; instructions.md, paths.yaml and glossary.yaml describe their own KB and are refused. The path is the same in the target (no rename, since a skill's name must equal its directory) and must be free there: an occupied slot, or one whose parent holds a name that differs from it only by case (a checkout on a case-insensitive filesystem could not hold both), refuses the whole call, nothing is overwritten. Files are copied byte for byte, execute bit included. Validated first, any one refuses the call and changes neither KB: a symlink in a unit or its parents (either KB), a non-regular file, a file over the 256 KiB artifact cap, more than 500 files or 16 MiB in one call (all its artifacts together), a file gitignored in the source or in the target (it would not be committed) (junk files, kb.JunkPatterns, are no part of an artifact: never carried, never a refusal, and removed with the source directory), a SKILL.md/hook.json/agent/mcp/template that fails the validation artifact_write applies, a duplicate path, a control character in a path. One commit per KB with the Moved-To/Moved-From trailers, then both are pushed. Needs a server mounting both KBs, both git-backed, and the whole-KB write grant on both. Never refuses for: placeholder keys the target's paths.yaml does not declare (undeclared_placeholders), sops files (sops_warnings), text in the source that still names a moved unit by path, which is not rewritten (references_in_source, <file> → <unit>, at most 100 entries, references_truncated: true when the list was cut short or the scan stopped at its budget of 5000 files or 32 MiB read; the activity log is not scanned), or the target's artifact lint findings for the moved paths, e.g. skill_missing_perimeter and cross_kb_path (findings_by_kb:{<kb>:[...]}, the same key as concept_transfer). Clients are untouched: one bound to the source loses the skill at its next sync. The audit entry records paths, joined and bounded.
concept_batch(operations) Atomically writes/patches several distinct concepts as one logical operation: one git commit, one summary log.md entry, either every operation applies or none does (D125): atomic per call (WriteConceptBatch, internal/kb/batch.go) — one call is one commit, all or nothing; beyond the operation cap (conceptBatchMaxOps, §Enforced limits) separate calls are separate commits, so a chunked run is not one transaction. A stale_write on an operation keeps its operation N of M (<id>) label and carries that concept's current content_hash and last-changing commit as in concept_patch; a rollback that itself failed (rollback also failed) ends with the recovery sentence of concept_move, because the tree may hold part of the batch. Each entry is write (frontmatter, body, optional if_match — absent means create-only, required to update an existing concept) or patch (required if_match, optional frontmatter shallow-merge, frontmatter_append/frontmatter_remove and unset array — any may be the only change — and the same single/edits Edit-tool semantics as concept_patch). Bounded: capped operation count and aggregate content size (a write counts its body and frontmatter, a patch the edits it carries and the frontmatter it changes, not the body that results, so many small patches on large concepts fit, D318), duplicate/invalid IDs rejected; every operation — including each Map's strict-ontology palette (only a type the operation introduces, D1314) and required-field contract — is validated before anything is written. A creating write (no if_match, concept absent) may set index_entry: true to add its curated index entry under ## New pages in the same commit, with the concept_new rules and a top-level curated_indexes note list; on a patch or an update it is refused at preflight, and the index edits are part of the all-or-nothing batch (D714). A third entry, delete (id, optional if_match; any other field is refused), removes the concept in the same atomic commit (D1327): a link from a page deleted in the same batch does not block it, and the gate judges nothing on a page that is gone (no orphan), so two pages that link each other are deleted in one call; a linker the same batch writes or patches is judged on its content after the batch, so a patch that drops the link plus the delete is one call; a link from any other page, or one the batch leaves in place, refuses the whole batch with inbound_links naming the linkers (up to 20 the caller can see), as concept_delete does, and a Source cited by a page outside the batch counts as linked. An unknown id is not found; an expanded concept is deleted when the same batch deletes every concept below it (its satellites, so none is left behind or removed unlisted) and it owns no asset: a satellite left out, an asset, or a write/patch below it refuses the whole batch, the asset case with a pointer to concept_delete (force), which is unchanged; the emptied directory goes with it (#1359). Each deleted entry of the result is {id, deleted: true} (no content_hash). Move, expand, assets and any other index edit stay out of scope: use concept_move/concept_expand/asset_*/index_patch for those. For confined single-concept edits use concept_patch's own edits batch (D76) instead; for renames use concept_move (D72); this tool is for a multi-page refactor that touches several concepts at once, where separate concept_write/concept_patch calls would leave partially-aligned intermediate commits if interrupted. Each result entry carries findings when non-empty (structural ones too, D312), and the tool-parameter-key rejection of concept_write/concept_patch applies per operation (D289). Each entry also carries the repeated_fact findings of concept_write (D351), with one lookup budget for the whole batch. Reports map_oversize once per map crossed, on the result entry of the last created op of that map (patches do not count, D716). An unknown op is refused; a move-like one (move, rename, mv) points to concept_move.
concept_delete(id, [if_match], [force]) Permanently removes a concept from the KB (git commit). if_match mismatch fails with stale_write carrying the current content_hash and last-changing commit, as for concept_patch. An expanded owner with assets also refuses deletion until force: true; force deletes assets and the owner index but preserves satellite Markdown concepts. Refuses with inbound_links: (listing up to 20 linking concepts the caller can see) when other concepts still link to it (D271); links are never rewritten — relink the linkers in the same concept_batch, use supersede to retire the page or concept_move to rename with backlink rewrite. In a git KB force: true does not lift this: the write gate refuses a delete that leaves a broken link (D907); force only matters in a KB without git, and for an expanded owner with assets. A Source cited through another concept's provenance counts as linked, and its citers are listed the same way (D278). Links from a concept hidden from the caller neither block nor appear; self-links and an expanded concept's own satellites do not count. With force in a KB without git the result carries a Warning: N concept(s) still link to … line.
concept_merge(satellite_id, if_match, [heading]) [A] Folds a satellite into its own expanded parent in one commit (D160): the satellite's body is appended to the parent's index.md under heading (default: the satellite's title, else the parent's id), its own relative links are rebased to the parent's directory, every inbound link (markdown and wiki-link, siblings and the links in the root and map index.md files included) is redirected to the parent, and the satellite is deleted. satellite_id has three segments (map/concept/child) and its parent must be expanded; if_match is the satellite's content hash. Only a satellite into its own parent: merging arbitrary concepts is out of scope. Judged by the write gate like every concept_* write; returns {merged_into, heading, rewritten, replacements}.
concept_collapse(id, if_match) [A] The inverse of concept_expand (D160): <id>/index.md becomes <id>.md under the same ConceptID, so no inbound link changes. id has two segments; if_match is the expanded concept's content hash. Refuses, naming them, when the directory still holds satellites (concept_merge or concept_move them first), assets (asset_delete first) or any other file (a hidden one included), and when <id>.md already exists. Returns {id, path}.

Governance

Tool Purpose
validate([scope]) [R] OKF compliance (frontmatter, type, reserved files).
lint([scope], [scope_neighbors], [check], [limit], [offset], [digest]) [R] Every finding is a problem to fix (D947): there is no severity, no floor and no count by severity. A clean scope answers {count: 0, findings: [], findings_in_page: 0, findings_after_filter: 0, offset: 0, counts_by_check: {}}, the shape of a non-empty one (D318). check (one name or a list; an unknown name is an empty page, not an error), limit and offset page the findings (D324), like kb_review and work_list, so a session can work one check at a time (lint(check: "bare_link_list", limit: 50, offset: 0); kb_repair takes one check, a list or "*", kb_review filters by kind). Findings are sorted by the registry's category order (lint.Categories: pages, templates, validity, links, values, maps, artifacts, kb), then check, path and message ascending, so an offset is stable and the doctor knows where to start; the order is not a judgement of gravity. The sort lives in lint.Order, after the cached lint.Run, and the Atlas lint endpoint and gate_check inherit it. A page holds at most lintMaxPage = 200 findings (no limit means that cap); next_offset and truncated appear when more follow. Response adds findings_in_page, findings_after_filter (survivors of check, the number the paging runs on), offset. count and counts_by_check are computed on the full input and describe the KB's state, not the view: check never changes them. gate_check does not page: its verdict is global. On a large KB the digest modes fold the output instead of paging it (#841, D841): digest: "by_map" adds counts_by_check_and_map, the KB-wide counts_by_check broken down per map (a path's first segment; an artifact finding — skills/…, agents/… — groups under (root)), so "where is the debt" is one call; digest: "paths" needs exactly one check and answers {check, distinct_paths, count}, the check's unique paths sorted, paged by limit and offset like the findings (count is the total, next_offset/truncated when more follow); digest: "compact" returns the findings of the page grouped by path as {path, checks: {<check>: n}}, dropping the message (rows in order of first appearance; limit and offset still count findings). For the distinct paths of one check on a large KB, digest: "paths" is shorter still. They share the filters (scope, check) of the paged form; by_map and compact keep the usual page fields alongside. A limit above the cap is clamped and flagged with limit_clamped: true. Runs deterministic broken-link, stale-claim, orphan, contract and structural checks. orphan is a concept with no incoming link, except a map's top-level concept, which its index reaches — unless it links to nothing either: the index is not an edge, so that concept is a node connected to nothing in the graph, and is an orphan too (D306) — except the only live concept of its map (or of the KB), which has nothing to link to yet; the next page links as usual (D1099). The graph checks (D243) are link_to_retired (a live map concept linking a deprecated or superseded one, naming its successor) and broken_relation (a superseded_by that names no concept, or the concept itself). They are computed on the whole KB and reported only for concepts in scope, so a scoped lint gives the same verdict. The graph-shape and size checks — island (a group of 2+ concepts linked to each other and to nothing else), cut_concept (a concept whose removal disconnects 3+ others), map_misfit, concept_oversize, map_oversize, facet_sprawl and missing_value_contract — are observations, not findings (D947): kb_review lists them (kind observation) and lint never returns one. Two checks read a concept's trailing links section (a heading such as Collegamenti, Links, Related, See also; it ends at the next heading of any level, D827): duplicate_link (a link written both in the text and in the section; always fixable, D307) and bare_link_list (a section of links with no word on why) — the section itself is never flagged, because for most pages it is where the links live (D287). A third, reciprocal_link_item (D301), is a link-only item whose target already links back anywhere in its body: the backlink keeps the edge navigable, so the item is a second write to keep in sync; it carries drop_link_item, is never conformance debt and never applied unattended, and a target already reported as duplicate_link is not reported again. scope_neighbors=true adds one-hop graph neighbors. There is no model-backed/deep mode. lint is exhaustive because it is the tool an operator calls to see findings (D186). A finding whose remedy is mechanical carries fix: {kind, field, from?, to?} (the kinds are the ones the kb_repair row lists), omitted otherwise; gate_check returns it too (D289, D295, D296).
repair_revert(sha) [A] Reverts one kb_repair or auto-repair commit as a new commit (D323): the commit's subject must start with kb_repair or its Reason trailer with auto-repair; any other commit, a merge, a hash outside the current history, or a tree with uncommitted changes is refused, and a revert that conflicts with a later change is undone and refused, leaving the KB as it was. Written through the ordinary write path (lock, commit repair_revert: <sha7> (<subject>), sync). Whole-KB write resource. cartographer kb repair <kb> --revert <sha> calls it.
kb_repair(check, [scope], [dry_run], [limit]) Applies the mechanical fix of one check's findings, or of a list of checks, or of all of them (check is a string, an array of strings, or "*" for every lint.FixableChecks; see below) (lint.FixableChecks: stringified_list → listify_field (D314), nonstandard_field → rename_field, tool_param_field → drop_field, broken_link → rebase_link (an empty to drops a self-link, keeping its label, D310), index_link_form → rebase_link or rewrite_wiki_link (D310), duplicate_link → drop_link_item or rewrite_link_item, reciprocal_link_item → drop_link_item, invalid_field_value → set_value or list_value (D1197: one finding per bad item of a list field; a removal, list_value with an empty to, is applied only by this tool, never by the write path or the background repair), prose_value → split_value, title_h1_mismatch → sync_h1 (D315: the first heading becomes the title) or set_value title (D357: the heading is the good side), missing_title → set_value title, repeated_link → unlink_repeat, unknown_type → set_value type, template_missing → set_value shape, template_type_mismatch → set_value type, template_field_value → set_value, title_quality → set_value title (decorative characters only), link_to_retired → retarget_links (successor live), index_lists_retired → drop_index_entry, contract_legacy_key → rewrite_contract, template_section_alias → rename_heading, template_section_order → reorder_sections (D352), value_case_variant → set_value status, unmapped_folder → scaffold_map (D357: a folder, not a concept; the response says found_folders), legacy_path → replace_prefix, legacy_tool_name → strip_tool_prefix, junk_file → untrack (#1377: a tracked junk file leaves the index with git rm --cached, the working-tree file stays; never applied unattended), legacy_skill_name → move_skill (D958: a skills/<old> directory moves to the cartographer- name and its name is set; both copies are left when the new name exists, as a conflict skip), missing_frontmatter → add_frontmatter, missing_type → set_value, unparseable_frontmatter → quote_value, nonslug_file_name → move, D356) across the KB; a concept is written through kb.RepairConcept (D356), which accepts a page that is as invalid as it was but never one that is worse, so a fix is not blocked by an unrelated missing type; move goes through concept_move's code path (inbound links rewritten, no fix when the slug is taken); replace_prefix rewrites the body, every declared prefix of one concept in one pass, longest first (D316); rebase_link, drop_link_item, rewrite_link_item and the prose half of split_value edit the body (D295, D296). Every duplicate_link carries a fix (D307): the link leaves its item together with the words that go with it, since the text already links it; an item that still holds other links keeps them (rewrite_link_item, separators ·, ;, |, , outside link labels), one left with none is dropped, and a links section left empty loses its heading unless the page's template requires it or a deeper heading follows (D827). dry_run defaults to true and writes nothing: the response is {check, dry_run, planned: [{path, fix}] (first 50; dry run only, an apply omits it, D318), planned_total, found_total, found_concepts, applied, skipped: [{path, reason}], contracts_rewritten?}: planned_total counts the fixes this call covers under limit, found_* the whole job in scope. With dry_run: false, each concept is written with the content hash read at listing time as if_match, so one changed since is skipped (stale_write), never overwritten; renames are decided per target field: several synonyms of one standard field (date and updated both meaning timestamp) holding one value collapse into it, while different values (or a target already present with another value) are left for a person as a skipped entry naming them all — and the concept's other fixes are still written. A rename keeps the key's position and rewrites the required_fields / field_values of the map's _map.md that name the old key, in the same commit. With reciprocal_link_item, a run never drops both items of a mutual pair: when it already dropped B→A, A→B is skipped (mutual pair: other side already removed), a guard behind the lint, which does not flag such pairs (D309). One call is one commit (kb_repair: <check> (<n> concepts)) with one log.md entry; nothing applied means no commit, so a second run is a no-op. limit repairs at most that many concepts. A check with no mechanical fix is an error that lists the ones that have it and points to lint. legacy_tool_name repairs artifact files, not concepts (D316): the response says found_files instead of found_concepts, each file is rewritten under artifact_write's rules (no symlink, the sha256 read at listing time, the per-kind validation, the file mode kept), the commit is kb_repair: legacy_tool_name (<n> files). Artifact findings exist only in a whole-KB lint, so a scoped plan is empty. Several checks in one call (D1317): check may be a list or "*" (alone or inside the list; duplicates dropped; an empty list is an error). The names are validated all at once before anything is planned: one unknown name refuses the call and writes nothing. The server fixes the order, not the caller: the lint.AutoRepairOrder checks first (D352: fixes depend on each other), then the other fixable checks in lint.FixableChecks order; each check is planned after the previous one was applied, under the one lock, so hashes are fresh. dry_run still defaults to true (kb_repair(check: "*") plans, dry_run: false applies); a multi call applies what an explicit call applies, removals (invalid_field_value with an empty to, reciprocal_link_item, broken_link) included, and the response says how many per check (removals). The response is {dry_run, checks: [...], applied_total, planned_total_all, checks_run}: each entry has the single-check shape (plus removals when above zero), a check that found nothing is not listed, and a dry run lists at most 5 planned per check (an apply omits them). limit applies per check, scope to all. One call is one commit and one log.md entry, kb_repair: <k> checks (<n> applied) with a body line <check>: <applied> per check that applied something, so one repair_revert(sha) undoes the call (repair one check alone to be able to revert it alone); nothing applied means no commit. A hard failure inside a check stops the loop and the error names the checks already written (uncommitted, revert or re-run). A single string answers exactly as before. Whole-KB resource, core. Never run on upgrade or startup; judgement fixes belong to the cartographer-kb-doctor skill (D290).
kb_review([kind], [scope], [limit], [offset]) [R] The cartographer-kb-doctor's work list (D298): ranked candidates the server builds deterministically and never decides (D14). Each item is {kind, concepts, evidence, suggested_action, weight, check?, term?, proposal?, template_slug?, template?, templates?, map_update?, impact?}. Kinds, in ranking order: duplicate_candidate (two concepts of one type with the same resource and titles sharing at least 30 % of their words (D313: a dozen tasks on one cluster are not duplicates), or two concepts of one map whose folded titles share at least 60 % of their words, except a satellite whose title extends its parent's, the split concept_expand makes, and consecutive pages of a series (D345: titles equal but for period tokens - a token with a digit, or q1-q4/h1-h2 - and ids whose basenames differ only in those positions, e.g. archive-2026-q2 / archive-2026-q3; the same rule silences the creation-time similar advice), and except a pair each page links the other in its body (D854: any body link outside code counts, a one-way link or a map index does not; a satellite and its parent, and titles made of the same words, never close this way, the action then asks to retitle one first); in a resource group the item stays while any similar pair is open; suggested_action names concept_merge when one is a satellite of the other, else merging or linking each page to the other), template_proposal (D352, D720: in a map with three or more pages of one type and no templates declared, or mostly unbound to the templates it declares, the template those pages already follow, or, whatever the page count, the existing templates/<slug>.md that at least 60 % of them already follow; one item per connected set of maps and template slugs (every map and every slug in exactly one item, #1196), with concepts naming each map as <map>/_map; the item carries templates ({slug, type, template, pages} per slug; template_slug and template only when it proposes one), the ready template text (which carries x-template.open_sections: true when at least 60 % of the pages have headings outside its sections, or when it has none, or when the closed form would fail most of a map; only standard fields are required, frequent non-standard ones are listed in evidence) and the map_update arguments, or one map_updates entry per map when it spans several, and impact (per map: pages and new_findings the map's pages would gain, by_check, unbound_types (pages gaining template_missing, per type), at most five examples, computed with lint's own template checks and net of what the pages already report, so a map that already fails is not blamed), also summed in evidence; both read every page of the map, so a caller who cannot see the whole KB gets the proposal without impact and without that sentence; the server never writes it, ranked right after duplicate_candidate), zombie_work (a concept still open — stale_open, or an open-phase status — that links a retired concept from an origin/dependency section (D345: a heading starting with origin, origine, depends on, dipende da, blocked by, bloccato da, derived from, derivato da, prerequisite or prerequisit, at any level, to the next heading of the same or higher level, code masked; a link in prose is a precedent and does not count), read from the link graph; link_to_retired and stale_open still see body-wide links; a retired concept linked by 3+ open ones is their common origin rather than each one's subject, so they get one item naming it first, and only other retired targets make per-concept items), harvest_candidate (D322: in a journal, a concept whose status is in the done or resolved family, not yet archived, with a timestamp at least harvest_after days old (default 45; the state is the map's open_field when set, D347); evidence counts and names the H2 sections that look durable (cause, lessons, diagnostics, useful commands, notes, resolution, fix, workaround, in English and Italian), weight is the age in days; the doctor carries the facts into live pages and archives the entry, the server never judges what is durable; ranks right after zombie_work), lesson_unpromoted (D1078: in a journal, a concept with status archived that no live page of a non-journal map links (a link from another journal entry, a digest or a retired page does not count) and whose body has no blockquote line starting > Archived that contains no durable lessons; disjoint from harvest_candidate by status; weight is the age in days; debt; ranks right after harvest_candidate), index_unclassified (D1078: a map whose index is curated and still lists entries under ## New pages, where index_entry parks a created page (D714); one item per index, concepts the existing pages linked there (at most 20, sorted), evidence the total, weight the count; ## Moved here is not covered; debt; ranks right after scattered_work), status_reclassify (D321: an active page of a journal whose open_statuses does not list it; suggested_action proposes reference (type assessment, solution, analysis or reference), done (an Esito/Outcome/Closed section), in-progress (unchecked items or a Next steps section) or blocked ("waiting for" and the like), else says the operator decides; ranks right after zombie_work), repeated_fact (D301: one normalised line of 40+ bytes, or one table row, in 3+ concepts — headings, code, table header and separator rows, link-only list items, template blockquote lines and the archival banner of an archived entry (its first blockquote line above the first H2, #1194) excluded; concepts are all the owners, evidence quotes at most 120 bytes, weight the copies; the write tools report the same finding at write time for the lines they add, D351), read_hotspot (D301: a concept with 50+ inbound links and a 16 KB+ body; weight in-degree × KB; whole-graph; its action suggests concept_expand only on a direct two-segment id, child pages on an expanded one, sibling pages at the depth limit), promotion_candidate (in a map whose contract declares promote_to, a concept with 5+ consecutive numbered items or an H2 matching procedure_headings, and no link into promote_to), scattered_work (D302: in a map whose contract declares work_map, a concept with unchecked items or an open-phase status and no link into work_map; weight the item count, evidence the count and the first item's section), map_naming (D304: with 3+ titled maps, titles that mix a subtitle (—, –, -, :) with none, or Title Case with sentence case, judged on words of 4+ letters after the first, acronyms skipped; one item naming every map as <map>/_map, evidence listing each folder and title so the agent also judges language. D315 adds individual titles: over 30 characters or a subtitle over 20 characters, listed as poor map titles even when the set is consistent; a title its folder does not echo is the write's warning only, since no page state could say it is right as it stands (D907); whole-graph), glossary_gap (an acronym- or mixed-case-shaped term outside code in 10+ concepts, absent from glossary.yaml and from every map with glossary: true; term names it; universal technical terms (API, HTTP and its methods, JSON, GB, …) and an all-caps word of 3+ letters whose lowercase form is an ordinary word in as many concepts (NON) are not terms (D307)), lint_judgement (one per stale_open, closed_with_open_items, template_section_missing, bare_link_list finding, and broken_link without a fix; check names it; weight is the check's category order, earlier categories first, never a severity), observation (D947, last: a state of the KB with no single right answer, so no lint finding — check names it: cut_concept a page whose removal disconnects 3+ others, island a group of 2+ pages linked to each other and to nothing else, map_misfit a page whose links point mostly into another map, concept_oversize a body over 30000 bytes, map_oversize a map with more than 50 top-level concepts, facet_sprawl a tags facet of 30+ values half of them used once, missing_value_contract a field used with a few recurring values and no declared vocabulary, carrying the proposal; facet_sprawl carries one too (D1197: the closed tags vocabulary, with drop the tags it leaves out); the item about a map names it as <map>/_map, and suggested_action says what to do; the ones that read the whole graph or a whole map need the whole KB; weight is the category order as for lint_judgement). A lint_judgement item is a lint finding of one of those checks wrapped as a work item to be ranked, not an additional finding: lint and kb_review count the same findings, so adding their totals counts them twice (D318). Within a kind, heavier weight first, then concept IDs, so pagination is stable. kind and scope (a concept-ID prefix an item must name) filter; limit defaults to 20, capped at 100. Response {total, by_kind, offset, limit_applied, items, next_offset?, limit_capped?, limit_max?}: a limit above the cap is clamped and the response says so with limit_capped: true and limit_max (D318), so a caller never takes a clamped page for the whole list; by_kind counts the scoped list before the kind filter. Nothing dismisses an item (D907): each kind closes on the state of the pages it names, so an item that is right as it stands is settled by writing that state — duplicate_candidate the two pages linking each other (D854), template_proposal the map declaring templates its pages follow, zombie_work the work closed, its origin link retargeted or moved out of the origin section, harvest_candidate the entry archived, lesson_unpromoted a live page linking the entry (or the no-lessons header), index_unclassified the entries moved out of ## New pages, status_reclassify the status changed or listed in the journal's open_statuses, repeated_fact the copies replaced by links to one owner (or the line moved into a template), read_hotspot a smaller body or fewer inbound links, promotion_candidate a link into promote_to, scattered_work a link into work_map or the items closed, map_naming titles that share one scheme, glossary_gap the term defined, lint_judgement the check no longer reporting, observation the page state its docs/data-plane.md §Observations. Every suggested_action ends with the same closing sentence (nothing dismisses an item; it closes when its pages reach the state asked for, a right-as-is page being settled by writing that state with the reason in the commit). Computed on demand, never stored, through the kb_status cache (D294); items naming a concept the caller cannot see are dropped, and a whole-graph item (zombie_work, read_hotspot, map_naming, lint_judgement of a whole-graph check, an observation that reads the graph or a map) needs the whole KB. Whole-KB resource, core.
work_list([scope], [include], [where], [stale], [fields], [order_by], [limit], [offset]) [R] The KB's open work as a view, not a type (D302): every concept in an open phase for its map (any type; open_statuses, else the defaults, never active (D321), draft only where open_statuses lists it) and every concept with unchecked - [ ] items outside code, whatever its status. Also work, by what the page is rather than its status, is a concept with a reason (D948): contradiction (an open Contradiction, one without resolution_status included, as kb_status counts it), gap (an open Contradiction of kind missing_context / open_question, D273) or review (a page past its review_after, unless it is open by its status already or finished: a done or resolved status, archived, deprecated or superseded); a concept with a reason has open_phase: true. Each entry is {id, title, type, map, status, open_phase, reason?, timestamp?, age_days?, stale, items: [{text, section, line}], fields?} (reason is omitted for a concept open by its status or its items); text is cut to 200 bytes, section is the nearest preceding heading, line is 1-based in the body, stale is stale_open's threshold (suspended by a future review_after, D321) extended to a concept whose items are that old. include is all (default), concepts (open-phase only) or items (with unchecked items only); where has concept_list's syntax; stale: true keeps stale entries; fields returns the named frontmatter keys' scalar values; order_by sorts by frontmatter keys as strings, missing last, before the default order (map, stale first, oldest timestamp, id). limit defaults to 50, capped at 200. Response {total, open_concepts, by_status, by_reason?, by_map, open_items, entries, next_offset?}, counts over the filtered set before paging: total counts entries; open_concepts only those open by their status or carrying a reason; by_status only the open-phase ones without a reason (grouped by the entry's state, the open_field value, when it has one, D347) and by_reason the ones with a reason ({reason: count}, omitted when there are none), so by_status does not sum to open_concepts: the rest is in by_reason. A closed concept listed for its items never counts as work in its status, and open_concepts equals kb_status.work.open_concepts. The server returns and orders by a KB's own priority/owner/due fields, never interprets them. Read-only: work changes through concept_patch/concept_write. Computed through the kb_status cache (D294, keyed also on the day), visibility applied outside it. Collection resource, core.
commit_gate(changed_ids) [A] Blocks when open Contradictions are involved in the diff. Knowledge gaps (contradiction_kind missing_context / open_question, D273) never block.
gate_check(changed_ids, [scope], [scope_neighbors]) [R] Combines validate + lint + commit_gate in a single tool (lightweight local gate). A finding is a problem (D947): pass needs zero lint findings in the scope it checks, no validation error and a passing commit gate, so a KB with old debt fails the whole-KB gate until it is repaired. changed_ids may be empty: that is the whole-KB gate for a session end, validate and lint over the scope with no commit gate (it checks the contradictions around the changed concepts, so there is none to check) and gate_blockers: [] (D318). scope narrows both the validation and the lint to a path prefix: with a scope the gate is that scope's lint as before. With changed_ids and no scope it is the cheap session-end gate (D312): validate, the frontmatter checks and lint.ScopedCheck over exactly those concepts (as in a write's findings), with the commit gate, not a whole-KB lint; the whole-KB structural checks stay out, which the response says with lint_scope: "changed_ids" ("scope" and "kb" for the other two). The whole-KB gate is an empty changed_ids. The scope is never inferred from changed_ids when the caller gives one. A lint finding that restates a validate error on the same path (missing_frontmatter, unparseable_frontmatter, missing_type, D356) is dropped, so each problem is listed once, under validation_errors. lint_findings lists every finding (in the order of lint, no floor hides any), with lint_count and counts_by_check; validation_errors and gate_blockers are listed too. repeated_fact is not among the changed_ids findings: it needs a previous version and the index (D351).
conflict_resolve(contradiction_id, resolution, [reason]) [A] Closes an open Contradiction.
contradiction_report([scope], [status], [kind]) [A] Lists contradictions, filterable by scope, status and kind: an exact kind, gap (both gap kinds, D273), contradiction (every non-gap kind, including none); empty = all.
kb_status() [R] The kb field names the KB that served the call (D144). Aggregate metrics: total concepts, per type, per status (concepts with no status field are excluded, D131), stale ones, open contradictions (gap kinds excluded). open_gaps (D273; omitted when zero) counts the open knowledge gaps — Contradictions of kind missing_context / open_question — as total, by_kind and recent (up to 10 {id, title, kind, involves?}, newest timestamp first, then ID descending), so an agent sees them without the advanced tools. Plus the replication facts an agent needs without reaching for sync_status (D145): has_remote / remote_url (origin, credentials redacted; a KB with a remote syncs, one without never contacts anything, D903), push_state, push_last_error, unpushed_commits (null when no trustworthy remote-tracking comparison exists), behind_commits (how far the clone is behind its remote-tracking ref as of the last fetch: no network on read; null when unknown), last_sync_in_at (the last successful fetch+pull, i.e. how fresh behind_commits is) and last_push_at (the last successful push since this server process started; null until the next push, D713), branch (the checked-out branch, "" on a detached HEAD) and branch_source (where the canonical branch comes from, as in sync_status; both absent for a KB that is not a git repository, #1092). sources (D278; omitted when the KB has no Source) counts the source ledger as {pending, ingested, skipped}. open_markers (D297; omitted when zero) is {concepts, markers} from the open_marker findings the caller can see. conformance (D290) is the KB's drift from the current standard over the findings the caller can see, only the checks in conformanceChecks (internal/mcpserver/conformance.go), the structural drift the server treats as actionable debt; lint counts every check, including content-level ones (orphan, empty_concept, …), so the two numbers differ by design and neither is wrong (D318): findings (the number of findings of the D289 checks plus broken_link, broken_relation, link_to_retired, index_lists_retired, legacy_archive_descriptor, prose_value, invalid_field_value, stale_open, closed_with_open_items), fixable (those with a fix), repairable (D331: {check: count} of every finding the caller can see that carries a fix, conformance check or not — stringified_list, title_h1_mismatch, legacy_path too — the list a mechanical kb_repair pass works through), repair (a fixed string, present only when repairable is not empty: the one call that plans every mechanical fix, kb_repair(check: "*"), and applies them in one commit with dry_run: false), last_doctor (date of the newest log.md entry containing kb-doctor, omitted when none), next_doctor (last_doctor plus 14 days, kb.DoctorSuggestionDays, omitted when last_doctor is absent), lint_findings (D336: every lint finding the caller can see, the number the Atlas Health panel shows) and doctor_suggested (D299: debt — any lint finding, conformance check or not (D336), or review item other than an observation (D947: a state a maintainer may leave as it is is not debt, and the doctor still sees it in kb_review) — and the interval passed since last_doctor, or no session ever): when true, the cartographer-kb-doctor skill is the procedure. review (D298) is {total, by_kind} of the kb_review work list the caller can see. work (D302) is {open_concepts, open_items, stale} over the work the caller can see: open_concepts counts the concepts open by their status and those that are work by their reason (open contradictions, gaps, pages past review_after; see work_list). read_cost (D301) is {concept_bytes, neighbourhood_bytes}, each {p50, p90, max} (nearest rank): a concept's file size, and that of the concept plus its in- and out-neighbours each counted once — computed on the graph of the concepts the caller can see, so a hidden one is neither counted nor a neighbour; the whole-KB answer is cached on the graph generation. It costs one whole-KB lint per generation (D294): the result is cached on the graph-cache generation plus a stat stamp of the non-concept inputs (map descriptors, indexes, assets, glossary, paths, hooks, templates) and invalidated by any write or out-of-band edit; the per-caller visibility filter (D226) is always applied outside the cache. Read-only: no fetch, no network. search_misses (D247) lists the 10 most frequent queries of the last 30 days that found nothing — query (normalised), count, last_seen — sorted by count, then last seen; omitted when there are none. Each query is re-run on the in-memory index when reported (D319): one that now finds something carries resolved: true and sorts after every open one, which a resolved query never displaces from the 10. usage (D326; whole-KB callers only) is {scanner_enabled, supported_providers, partial_providers, unsupported_providers, artifacts_never_used, artifacts_stale, artifacts_active, stale_threshold_days} over the KB's skills and agents, from the reports clients posted to /api/usage; with no report at all it is {scanner_enabled: false, no_data: true, no_report_since}, because the server cannot see a client's scanner, only that nothing has arrived; no_report_since is the server's start time (D333), since the store is local state that an ephemeral data dir loses at every restart — a recent value means the next client sync refills it. A skill whose only signal is a Codex catalogue load counts as never used. lint_trend (D370; whole-KB callers only, omitted with fewer than two samples in the last 7 days) is {window_days, samples, first, last, delta}: the first and last daily sample of the whole-KB lint totals ({at, total, by_handler}) and last.total - first.total, so an agent can tell whether the KB is getting better; the samples are kept by the server, see deployment.md. server_version is the serving binary's version, and latest_version a newer release the server's own daily update check knows of (D254) — omitted when none is known; reading it makes no network call. On the routed mount only (/mcp/routed), the answer also carries connection (D712): kbs (the connection's set), mounted (every KB the server mounts that the caller reads, D1096: no other name is spoken), unbound (mounted but outside kbs; omitted when empty), unknown (names in the URL's ?kbs= the server does not mount, D756; omitted when empty, and the hint then adds a cartographer sync pointer), renamed ({old: new} for the names of the URL resolved through kb_aliases, D772: served under their new name, so the URL is stale; omitted when empty, and the hint adds the same pointer), server_started_at and a hint present only when there is something to act on (a name in unbound, unknown or renamed; each adds its own sentence, #1305), so an agent whose session predates a KB can spot it (run cartographer sync, then start a new session); kbs equal to mounted and nothing unknown or renamed carries no hint key. The per-KB endpoints keep the shape without it.
source_register(title, source_kind, [locator], [sha256], [timestamp], [body], [map], [ingest_status], [source_shape], [connector], [recipe]) Records a primary source in the source ledger (D278) as a Source concept <map>/<YYYY-MM-DD>-<slug> (the slug is the title under the write path's slug rule: accented latin letters fold to ASCII, other scripts are dropped, and a title left with no ASCII letter or digit gets source-<8 hex of the title's SHA-256>) (date from timestamp, else today UTC) in a journal (map, default sources, which must exist: it fails with a map_create hint). Deduplicates first — sha256 when both sides have one, else an exact locator — and then returns {id, duplicate: true, ingest_status} writing nothing (when the match is by sha256 but the locators differ, it also carries locator_mismatch: true and existing_locator: two locators with one content are often one document, so the agent decides, D720); otherwise {id, duplicate: false, curated_indexes}, plus notes when it set up the journal's source template (D1072). sha256 must be 64 lowercase hex, locator a URL or placeholder (never a machine path), ingest_status pending (default) | ingested | skipped. source_shape (document default, collection, feed, system; D1201) says how the thing is read and sits beside the free source_kind; connector and recipe are the names of a connector and a recipe in the worker's configuration (never a path or a command), and a locator, connector or recipe that is a URL with a password, a non-ssh username or a secret-named query parameter is refused: credentials never go in the ledger. Dedup is unchanged, so a collection or feed, normally without a sha256, dedups by locator. The registration goes through the write gate like concept_write (#1297) and, when the journal has a curated index, adds the page's entry to it in the same commit (a generated index lists it already; curated_indexes says which), so a registration leaves no index_incomplete and no orphan: a Source is a leaf by nature, and lint does not ask it for outgoing links. Whole-KB write scope: a narrowed token is refused. The audit trail records only map.
source_list([status], [scope], [limit]) [R] Lists Source concepts (with source_shape, connector and recipe when set), oldest timestamp first then ID — the order a backlog is worked in. status defaults to pending (* = all); limit defaults to 50, max 500. Entries {id, title, source_kind, ingest_status, timestamp?, cited_by}; cited_by counts the visible concepts listing the source ID in provenance, and cited_by_ids lists their IDs (D720). Entries the caller cannot see are omitted.
ingest_setup() [A] Writes templates/ingest-plan.md and templates/ingest-unit.md when absent (D1201), the shape of the IngestPlan and IngestUnit concepts of an ingest campaign (docs/data-plane.md §Extended concept types). Idempotent: an existing file, even a different one, is never overwritten (present, plus a note when it is not a template of the right type). Returns {written, present, notes} and warnings for a file that could not be written (a symlinked templates/), which never refuses the other. It creates no map and declares no template on any (the planner chooses the Maps, and a declaration makes a map strict): the notes name map_create / map_update. Whole-KB write scope; nothing is audited.
conflicts_list() [R] Lists open git rebase conflicts (read-only). For each entry: concept_id, local/remote SHAs, branch, files involved, detected_at, resolution guidance; kind: reserved marks a reserved file (concept_id is then its path) the server could not reconcile itself (D311). See also the cartographer-kb-conflict-resolve skill.
git_conflict_resolve(concept_id, strategy, [body]) Resolves a registered conflict (Step 4). strategy: ours (local version), theirs (remote version), edit (full content in body), union (both sides; a kind: reserved log.md only — a reserved file takes no edit). Records the per-concept decision; once every open conflict is resolved, it performs a single merge+commit+push and clears the degraded markers. See concurrency.md §Conflict registry.

Skills and Services

Tool Purpose
skill_list() [R] [A] Lists the installed skills (skills/) and the ones bundled in the binary. Field source: [installed] | [bundled].
skill_install(name, [force]) [A] Copies a bundled skill into kb.Root/skills/<name>/. Errors if already present; force=true overwrites.
service_get(service_id, [resolve_secrets], [reveal]) [R] Reads a Service. With resolve_secrets: true, resolves declared secret_refs (or legacy whole secrets_source, a list of files resolved in order; a key in two files is an error), lists the key names with <redacted> values and requires rw scope; reveal: true returns the values (D261). resolve_secrets and reveal are recorded in the audit trail.
secret_resolve(concept_id, [names], [reveal]) Resolves SOPS references declared by any concept; names narrows the declared values. Values are <redacted> unless reveal: true (D158); reveal is recorded in the audit trail.
secret_set(path, key, value) Sets a JSON Pointer in an encrypted SOPS file under secrets/; plaintext is never committed or returned. A missing file is created when the root .sops.yaml has a creation rule matching the path and the server's age key is one of its recipients (D1191); without a .sops.yaml, a matching rule or such a key it refuses. Needs rw scope.
service_list() [R] [A] Lists all concepts of type Service.

Client ↔ provisioning synchronization

Tool Purpose
sync_check([applied_revision]) [R] [A] Read-only. Returns the current manifest's revision and artifact list, including cryptographic signed verification output and separate built_in trust origin.
sync_apply(base_dir, [dry_run]) [A] Local operator only (stdio, or HTTP with authentication off): every token, admin included, is refused with forbidden: local_only, dry_run too, on the per-KB and the routed endpoint alike, because it reads and writes base_dir on the server host; remote clients use sync_pull. Materializes built-in and KB artifacts into base_dir (an MCP descriptor, a hook or an artifact shipping an executable file needs a local approval, signed or not, and is reported in needs_approval: this tool holds none, D1141); signed is a verification result only. A local signer is verified before applying. It also verifies the managed files on disk and restores what diverged, reporting those artifacts as healed (D139); there is no opt-out argument, since this path has no interactive user.
sync_pull() [R] [A] Read-only, no parameters. A KB's skill, agent, hook and mcp artifacts are built only from the files git tracks in its clone (D1325); a KB whose git cannot be read has its artifacts withheld and the reason in issues. Returns base64 file contents plus optional detached Ed25519 signature (algorithm, key_id, envelope version, value). Remote clients recompute hashes and verify pinned keys before materialization. KB-provided MCP descriptors appear only when the server-side allow-list permits them. Also returns placeholders (omitted when empty): the sorted kind:key path placeholders (D75) the KB cites — in the bodies of the concepts the caller may see (projected like the graph tools, so a key only a hidden concept cites is never listed) and in its own artifacts — for the client to resolve (D262). Also returns path_registry (omitted when the KB has no paths.yaml): the KB's declared placeholder keys, {"paths": {key: {description, default}}, "repos": {key: {description, remote, default}}}, with malformed entries left out and named in issues (D263). issues (omitted when empty) also carries the manifest's reasons for leaving an artifact out. None of the three is part of revision or signed; the server still expands nothing.
sync_status() [R] [A] Read-only. Returns local Git replication state (disabled when the KB is not a git repository, no_remote, clean, pending, failed, or degraded when writes are blocked), the last error/attempt, HEAD, best-effort unpushed count, behind_commits / last_sync_in_at / last_push_at (same semantics as in kb_status, D713), the checked-out branch and the canonical remote_default_branch last resolved (a mismatch blocks writes, D264) with its branch_source — server when the server file's kbs[].branch sets it (D1093), settings when the branch of the settings.yaml on the remote's default branch sets it (D335, D903), remote otherwise — and the push mode. A push the remote refused because the branch is protected is failed with a last_error that says so and what to do, and is not retried (D903). A push refused because of who authored or committed its commits (a membership or private-email rule) is failed too, but its last_error is an identity refusal (D1063): it names the identities found, says to set git.identity when none is configured, and points to cartographer kb reauthor <kb>; it never suggests unprotecting a branch.

No pull-request tools (D903). Cartographer pushes to the KB's branch itself and opens no pull request, so there is no pr_status or pr_finalize (a call is an unknown tool; a client skill, instruction or permission rule that cites either must drop it), and kb_status/sync_status carry no git_workflow, git_profile, git_sync, pull-request fields or last_forge_error. A push the remote refuses because the branch is protected shows in sync_status (see above).

KB-root artifacts

Tool Purpose
artifact_read(path, [encoding]) [R] Reads a KB-root artifact file (skills/<slug>/**, agents/<slug>.md, hooks/**, mcp/<slug>.json, instructions.md, templates/<slug>.md, paths.yaml, glossary.yaml) and returns its content, encoding (text or base64) and raw-byte sha256 (the if_match for artifact_write). Non-UTF-8 bytes are always base64.
artifact_list() [R] [A] Lists provisioning artifacts and KB-only templates by kind, with files, raw-byte sha256 and executable metadata. Templates are scanned directly because they are absent from provisioning.BuildManifest. The UI API's artifact routes share its enumerator (D238).
template_list() [R] Agent-visible template discovery: returns each template's slug, literal type, title, sorted variables and schema (description, sections, optional_sections, section_aliases, required_fields, optional_fields, field_values, open_sections, D352), never its body.
artifact_write(path, content, [encoding], [executable], [if_match]) Creates/updates a KB-root artifact file. encoding is text (default) or base64; the 256 KiB limit applies after decoding. instructions.md is refused over 64 KiB (D1110). executable is tri-state: omitted preserves an existing mode and defaults false on creation. Structured principal files remain UTF-8 and non-executable. Templates require parseable frontmatter with a literal non-empty type and only {{identifier}} variables in scalar/list values or body. paths.yaml (the KB's path placeholder registry, D263) is validated strictly: only paths:/repos:, slug keys, a required description, a default starting with ~/ or $HOME/, a parseable remote on repo keys — any other field or value is refused with the entry named. glossary.yaml (the KB's terminology, D276) is validated strictly too: only terms:, a list of {canonical, aliases, forbidden} with canonical required, every term a non-empty string of at most 80 bytes, and no folded string under two canonicals or both an alias and forbidden. A hook's hook.json is validated strictly too (D284): valid JSON, a non-empty event and command, and a relative command that stays inside the hook's directory; the result carries warnings for an event outside the declared vocabulary (accepted: a client may fire events the repository does not encode) or naming the hook-capable clients that will not fire the event (accepted: a hook may target one client). On an existing file, if_match (raw-byte sha256) is mandatory (already_exists if missing, stale_write if wrong; both name the file's current sha256); per-kind validation applies before the write. Registered on every KB: the token's write scope is the only gate. A template's x-template.* keys are validated (docs/data-plane.md §Templates are page schemas). This is the write end of what a materialized skill or agent advertises: their provenance block (D138) names this tool, the KB and the path to call it with.
artifact_delete(path, if_match) Removes a KB-root artifact file (and an eligible empty directory). A mismatched if_match is a stale_write naming the current sha256, as for artifact_write. Registered on every KB, like artifact_write, and in the core set with it.

artifact_write/artifact_delete go through gitWrap (lock, commit, sync).

See docs/sync.md for the full model (Manifest, Lock, Diff, layered triggers).

Multi-KB: agent clients use the routed endpoint /mcp/routed, where the KB is a kb tool argument (required for 2+ KBs in the connection's ?kbs= set, absent for one) — see the paragraph above. On the per-KB plumbing endpoints ?kb=<name> (query param) or /mcp/<name> (path) select one KB's isolated Server for the whole session and no tool takes a kb argument. Tool names are never prefixed (D288, D325).

Enforced limits

Each of these is a hard limit a caller hits unless it reads it first. The first three are stated in the description of the tool that enforces them, interpolated from the constant the handler checks, so a figure and its documentation cannot drift apart; the body limit is stated by its refusal; the last is reported by the skill_warning lint check, not by a tool.

Limit Constant Enforced by
10 MiB per asset kb.AssetMaxFileSize (internal/kb/asset.go) asset_write, asset_read
50 operations per batch conceptBatchMaxOps (internal/mcpserver/tools_write.go) concept_batch
512 KiB aggregate per batch (patches count their edits) conceptBatchMaxTotalBytes (same file) concept_batch
1 MiB per concept file kb.MaxConceptBytes (internal/kb/kb.go) every concept write (concept_write, concept_new, concept_patch, concept_batch, the move and transfer tools): the whole file, serialized frontmatter plus body, over the limit is refused with concept_too_large naming the total and its two halves, before anything is written; kb_repair rewrites are exempt. An existing page already over the limit is refused too, a frontmatter-only change included: it must be split (only kb_repair can still rewrite it)
500 lines per skill body maxSkillBodyLines (internal/skill/skill.go) skill_warning

required_fields, field_values, forbidden_fields and the curated index are lint contracts: a violation is a lint/validate finding. A write never introduces one, though: each is a check a write can avoid, so in a git KB the write gate refuses the write that would (D907) and an existing violation is left to the doctor. The refusal lists only findings on pages the caller can see. A strict map's concept_types is checked earlier still, by validate, which rejects an out-of-vocabulary type before anything is written. No contract key switches a check off (data-plane.md §No check can be switched off), and the map_create description says so.

Tool descriptions and their size budget

Every client pays the tools/list text on every round-trip, in both mount modes (D288), so the single copy is kept small (D285). TestServer_ToolDescriptionBudget (internal/mcpserver/tool_budget_test.go) fails the build when any tool description exceeds toolDescriptionMaxChar, naming the heaviest tools.

The total is budgeted as part of the fixed per-call cost, what an agent pays on every model call whatever the question: the managed instructions block its client holds plus the tools/list of its connection. TestFixedCallCostBudget (internal/mcpserver/fixedcost_budget_test.go) builds a routed mount over 1 and 9 fixture KBs (archives, skills and a 3 KB curated instructions.md each, fixedCostKBs), renders the block through the path sync takes (BuildManifest, then Apply for Claude Code) and measures eight cells in bytes: the block for 1 and 9 KBs, and the tools/list result for 1 and 9 KBs in each of the agent profile, ?access=read and ?access=read-min. It also bounds the block's growth per added KB and each profile's tools/list growth from 1 to 9 KBs (the routed kb argument). The budget table, with what each cell measured and when, is at the top of that file; go test -v logs the whole table with the sum per connection and its token reading (about 2.5 bytes per token, not asserted). A failure names the component and cell, the bytes over budget and in percent, the delta against the neighbouring cell (1 KB, the larger profile) and the heaviest tools or block lines. A budget is the measured value plus at most 10 % and is raised only with a decision (D285). The initialize guide of an unprovisioned client is pinned separately (TestRenderGuide_Budget, internal/provisioning: the operational section with the three procedures listed and the cold-start section, raised in D1316). Tool names, required lists, types and enums are untouched by the budget: only prose is.

What a description keeps: what the tool does, when to use it instead of its neighbour, and every rule needed to call it correctly (required preconditions such as if_match, refusal conditions, destructive effects, the enforced limits above). What it drops: D<n> references, rationale, response field listings and anything another tool's description already says. The rest lives on this page, which stays the full reference. Hiding a tool is not a way to meet the budget: a descriptor-bound host cannot call a tool tools/list does not advertise (D123).

Parameter details not repeated in the schema:

  • reason (every mutating tool, optional): stored in the commit as a Reason: trailer (at most 500 bytes) and shown by changes_since. The caller's client name is added alongside as an Agent: trailer (D718).
  • Defaults and caps: changes_since limit 100 (max 500), since 7d; concept_list limit 500; concept_history limit 20 (max 100, out-of-range values clamped); source_list limit 50 (max 500), status pending; search limit 20; link_suggest limit 5 (1-20); graph_context limit 10 (1-30), ids at most 10; graph_neighbors depth 1, direction out; log_tail n 20.
  • concept_list: where predicates are ANDed key=value / key!=value; scalar fields match exactly, list fields match any element, matching is case-sensitive, a missing key matches != but not =. timestamp_before/timestamp_after are strict bounds on the frontmatter timestamp, as RFC 3339 or YYYY-MM-DD (midnight UTC). Filters apply before limit. fields is capped at 8 keys (D343); more, or an empty key, is an error.
  • concept_read: the body is returned once; with_content: true returns content (frontmatter + body, the exact bytes) instead of body, which a verbatim rewrite needs; match greps body lines (50 windows max). section returns one section (the error lists the headings); outline: true returns {level, title, bytes} per heading; bodies over 60 KB come as an outline with a note unless full: true. rev (7-40 hex commit SHA, as listed by concept_history; branches and relative refs are rejected) reads the concept as it was; its content_hash is computed on the historical content and is not an if_match. index_get has the same full/outline/with_hash.
  • concept_patch / index_patch: if_match is required. The single old_string/new_string/replace_all triple and the edits array are mutually exclusive; edits apply atomically and in order, and a failing batch names the edit's index and writes nothing. frontmatter (concept_patch) is shallow-merged, may be the only change, and a null value removes a key (refused for a required key such as type); frontmatter_append/frontmatter_remove add or drop list items without resending the list (D342). concept_batch (one commit for up to conceptBatchMaxOps operations) is in the core set. A \s* in a body is \\s* in the JSON old_string: matching is plain substring, so an escaping mismatch in the client reads as old_string_not_found.
  • concept_delete: refuses with inbound_links: (in a git KB force=true does not lift it, the write gate refuses the broken links); a Source cited through another concept's provenance counts as linked (D278). Deleting an expanded concept that owns assets also needs force=true; satellite concepts are preserved.
  • concept_archive: nothing is written until id, to, status and the target slot are validated and if_match matches (already in <to> when the concept is already there). The status edit and the move share one commit; a late filesystem failure after the status write has the concept_move contract (error, no commit, no rollback). A source that is already deprecated or archived is allowed; a superseded one is refused (D883): its status and superseded_by are one story, so it keeps both and concept_move relocates it.
  • concept_transfer: validation reads both KBs and writes nothing; a refusal changes neither. The target is written and committed first, then the source. After the target commit its file listing is compared with every file the move wrote (a target-root ignore rule can leave a file out of the commit, and the source copy would then be the only one): a missing file undoes the target commit like a source-step failure, and the error names the files. A target-step failure discards the target tree, including the ignored copies git clean keeps, and leaves the source untouched; a source-step failure discards the source tree and undoes the target commit with a revert commit on top of it (history is never reset), and the error says which. Only if that revert itself fails do the concepts exist in both KBs, and the error then says so. D760, #845.
  • artifact_move: the concept_transfer contract (D782), the target-commit verification included: validation reads both KBs and writes nothing, the target is committed first, a source-step failure is undone on the target by a revert commit, and only a failed revert leaves the artifacts in both KBs, which the error says.
  • concept_move: every entry is validated before anything is applied (source exists, target free including against other targets of the batch, no path traversal, no duplicate source_id, and a type the target map admits: a move into another map whose concept_types do not list the concept's type is refused naming the admitted types, nothing written, #921; a rename inside one map is not judged). Links rewritten are [[old-id]], [[old-id#section]] and markdown links across the whole KB, services/ included; links to assets are left unchanged. A filesystem failure after a target was written is reported naming both IDs and is not rolled back.
  • asset_write: executable is tri-state (omitted: new files are non-executable and an existing mode is preserved). asset_list skips hidden files.
  • artifact_read: encoding defaults to text; non-UTF-8 content is always returned as base64.
  • atlas_overview structure: true: most central concepts (PageRank) and main link-graph communities.
  • changes_since links: true: links added and removed, and pages that became or stopped being orphans.
  • map_create / map_update: kind is map (default) or journal; a non-empty concept_types is the allowed vocabulary and makes the map strict (the mode is derived from it, D907). required_fields and field_values are keyed by concept type, * for every type: required_fields reports missing_required_field, a type's list adding to the * one; field_values maps a field to its allowed values (invalid_field_value), a type's entry replacing the * one for that type; forbidden_fields reports forbidden_field. Every curated index.md lists its map's concepts, <c>.md or <c>/index.md for an expanded concept (index_incomplete, always on, D907). map_update replaces each given key whole and removes it when given an empty list, "" or {}. Both return warnings (advisory, the write is not refused) when the title is off: the shape kb_review flags as map_naming, or neither the title's head nor its folder a prefix of the other, which only the write reports (D721, D907; the rule is lint.MapTitleIssues).
  • gate_check / lint: a finding is a problem (D947), so there is no severity to filter on; pass needs zero findings in the scope (D186, D947) and count is always the unfiltered total. scope_neighbors also lints the 1-hop graph neighbours of the concepts in scope. With changed_ids and no scope, gate_check lints only those concepts (lint_scope: changed_ids, D312; without the write-time repeated_fact, D351); an empty changed_ids is the whole-KB gate.
  • snapshot: appends a log entry and, in a git repository, a git commit.
  • concept_move across maps appends the plain entry - [title](path) under ## Moved here in the destination's curated index, with no suffix (D720), and removes the source's entry when the moved concept is that line's only link (D160); every curated index is maintained (D907), a generated one is the server's.
  • Ingest units are claimed with work_list and concept_patch (if_match, stale_write: as above), with no tool of their own: the protocol is in data-plane.md §Extended concept types (Ingest plan and units).
  • source_register: locator is a URL or a {{path:<key>}} placeholder, never a raw machine path; sha256 is the lowercase hex digest of the original bytes, computed by the caller; source_kind is free text (document, transcript, ticket, thread, web, dataset); timestamp is the source's own date. The registration is gated like concept_write (#1297): a title that title_quality flags, or any other avoidable finding, refuses it with the finding and writes nothing. The response carries warnings when the template setup failed, and notes when it wrote templates/source.md (not into a map that declares other templates, whose pages would be refused for it) or said why it did not declare the map (D1072); the Source is registered anyway.
  • kb_status: besides the concept metrics (per-type and per-status counts, stale concepts, open contradictions, open_gaps) it reports git replication (has_remote, remote_url, push state, unpushed commits), capabilities (each per-KB gate, state and controlling key), sources {pending, ingested, skipped}, read_access (D322, D778: {tracked_concepts, oldest, newest} of the reads, since — when reads began to be counted — and surfaced, the same three keys for search hits; absent until a read or a hit is recorded), search_misses (the 10 most frequent zero-result queries of the last 30 days), conformance (D290; when doctor_suggested is true maintenance is due: kb_review lists it, and the cartographer-kb-doctor skill runs it where the client has it, which counts every finding and every review item but the observations, D721, D947), server_version and latest_version. It is read-only and never hits the network. When the KB carries a settings.yaml (D746) a capability whose key the file sets names settings.yaml <key> as its setting, and the top level adds settings_findings (entries of the file that were left out, and a usage_stale_days raised to its minimum); a KB's policy comes from its settings.yaml only (D906), so no server value can lose to it. capabilities.secrets reads missing key when a sops file sits under secrets/ and the mount has no age key (D906); its hint (D1191) names the recipient the KB's sops files expect, says whether a key file in credentials_dir already holds it, and, for a key file added after the start, says to restart the service to load it (keys are read at start only). The write gate, repair on write, the background repair and kb repair --apply are fixed behaviour with no setting, so none is a capability; the doctor suggestion interval (kb.DoctorSuggestionDays) and a session's review budget are not KB state (D907).
  • Background repair (D323, D355): every day (kb.AutoRepairIntervalDays; HTTP serve only; the first run follows startup by a minute, or the last logged run plus the interval), and once more shortly after a git pull that moved HEAD (at least 10 minutes after the previous run, so edits that bypass MCP are repaired within minutes), the server repairs the KB in two stages (docs/data-plane.md §Check registry): every concept with a concept-local finding of a check safe to run unattended is repaired to a fixpoint and written once (at most 500 concepts per run, all checks together), then the cross-concept checks run, then the concepts they touched run through stage 1 again. It goes through the same write path as an agent: the KB lock, one commit per run (subject auto-repair (background), one body line <check>: <n> per check that changed something, trailer Reason: auto-repair (background)), the git sync and the stale-write guard. A KB with an open git conflict, or whose sync is degraded, is skipped. Each run is one line of .cartographer/auto-repair-log.jsonl ({at, skipped?, checks: [{check, applied, skipped, commit, error}]}, per-check entries kept; local, never committed). It is not a doctor session: it writes no kb-doctor log entry, so last_doctor does not move. Stopping the server stops it. Every check whose registered fix is safe to run unattended (AutoRepairSafe, listed by lint.AutoRepairChecks) runs here, on write and under kb repair --apply; no setting narrows the list, and kb_repair still applies the others on request. The heartbeat has no off switch: contract_legacy_key and the drop of a lint_ignore field from pages (D907) are part of the list.
  • Repair on write (D349, D355; always on, D907): concept_write, concept_new, concept_patch, supersede, concept_move and concept_batch apply the safe fixes to the concepts the call left on disk, inside the write handler and so in the same commit, in both stdio and HTTP, each concept repaired to a fixpoint (a chain of dependent fixes is one write). Only those concepts: a neighbour that findings reports (a linker with a new broken_link) is never rewritten. The fixes are every check the registry marks safe to run unattended (AutoRepairSafe) that a concept can fix alone; no setting adds to or removes from them. broken_link and reciprocal_link_item are never applied on write (they drop or rewrite links, D309); they stay with the timer and kb_repair, and so do artifact and map checks. The response adds repaired: [{check, count}] (sorted by check, omitted when nothing was repaired; per entry in concept_batch, aggregated at the top level in concept_move, a repaired: block in supersede), findings lists only what remains, and content_hash is the hash after the repair so an if_match chain works. A repair that fails never fails the write: the content stays as sent, the finding stays, the error goes to stderr. Nothing turns it off (D907).
  • Text by connection kind (D1101): the connection kind is ?provisioned=1 on the routed URL (D1097), carried on the request context; the Atlas is always addressed. It selects wording, never access. A provisioned connection and the Atlas get the texts that name a cartographer command byte for byte: the routed refusals and their routedScopeHint, kb_status.connection.hint, the answer to a prefixed tool name, the identity-rejection push_last_error and write sync warning. Every other connection (per-KB endpoints, stdio, an unmarked routed URL) gets the same facts in words, with no command and no skill as the only way, and kb_guide (procedure conflict-resolve) replaces the conflict skill in the descriptions and results every connection sees. TestGenericConnectionIsNeverToldToRunACLICommand fails on a new message that breaks this; its twin pins the provisioned texts. GitStatus.LastError stays the full text (it is compared by prefix); the neutral rendering sits beside it.
  • Doctor nudge (D299): after a successful tool result, the server appends one extra text block proposing a cartographer-kb-doctor session when the KB's doctor_suggested is true for that caller, only on a connection that declared ?provisioned=1 (and on the Atlas): a generic client has no such skill and its user is not the KB's operator (D1101). That check comes before the window is claimed, so a generic call never silences the provisioned clients that share the server's one bucket. At most one nudge per MCP session (the SDK session ID, so each client session that starts while a cartographer-kb-doctor session is due gets one) per 24 hours (an in-memory window, re-armed by a restart; a transport with no session ID shares one window, D358); the text asks the agent to propose a session to the operator and not start it without their agreement (D704: an agent works on a KB only when the operator asks, except a configured worker, D1258); never a caller without write access to the KB, never on kb_status or an error, and never the Cartographer CLI (clientInfo name cartographer: client.Call would turn the answer into a JSON array). The block starts with cartographer: KB <name> and carries counts only — lint findings, fixable, review items, the last session date — never KB content. The server proposes; it never runs a session or writes.

Read-only UI API

Beside the MCP surface the HTTP server serves /api/ui/v1, a versioned read-only JSON API for a human client (D226). It is not MCP: the browser does not speak JSON-RPC and no tool is added to the agent surface for it. It is not public either — it sits inside the same OriginGuard → bearer-token chain as /mcp, and isPublicPath does not exempt it.

Route Returns
GET /api/ui/v1/kbs The mounted KBs the principal can see something in, with status, readiness, capabilities (computed at start, as on /health: a secrets hint, D1191, may be stale there, kb_status is live), artifacts (whether the principal may read the KB's artifacts) and secrets (whether the principal may open the Secrets panel and read its files, D708; it is not the capability state, which is capabilities.secrets here and in kb_status: a KB whose capability is disabled still says true).
GET /api/ui/v1/kbs/{kb}/overview Visible collections with their concept and expanded counts and archive (D820: kb.IsArchiveMap, the rule index_lists_retired exempts by), concept totals by type and by status, lint counts by check (whole KB: from the kb_status lint cache, D294), and — only for a caller that can see the whole KB — the replication facts.
GET /api/ui/v1/kbs/{kb}/graph?scope=&limit= A bounded graph snapshot: sorted nodes with their collection, title (when the concept has one), type, status, both degrees, pagerank and community, directed deduplicated edges, broken link targets kept apart from the nodes, and communities [{rank, size, anchor, slot}]. PageRank and communities are computed on the whole graph the caller can see, before scope and limit, so a scoped view keeps whole-KB colours (D244); communities discount boilerplate links and anchor on the strongest internal ties, as in atlas_overview (D317). scope is one top-level collection; limit defaults to 2,000 nodes and is clamped to 5,000, with the effective value echoed back. Past the limit the nodes with the highest PageRank are kept, and past 20,000 edges those whose weaker endpoint ranks highest; the output stays sorted.
GET /api/ui/v1/kbs/{kb}/concept?id= Title, parsed frontmatter, body, outline, content hash, visible inbound/outbound neighbours and this concept's broken link targets; for a caller that can see the whole KB, used_by — the skills, agents and hooks that reference it explicitly (D286); supersedes (visible concepts that name this one as their superseded_by) and superseded_by (its successor, "" when none or not visible to the caller).
GET /api/ui/v1/kbs/{kb}/search?q=&scope=&limit=&include_archived= The search tool's own response (results with id, title, snippet, score, archived, the optional read of D1207 (the Atlas ignores it), and archived_fallback when nothing live matched), filtered with the caller's principal. q is required; limit defaults to 20 and is clamped to 100 (the tool has no clamp). Archived pages stay hidden, as for an agent, unless include_archived=1 (D820). Unlike the tool, it never records a search miss and carries no related block (D781): the Atlas searches as the reader types (D286) and has nowhere to show the block.
GET /api/ui/v1/kbs/{kb}/retired What the KB has retired and where it went (D820): concepts — every visible concept whose status is deprecated, superseded or archived, each {id, title, status, map, timestamp, superseded_by?, superseded_by_title?, supersede_reason?, retired_at?, from?, reason?}, newest retirement first, those with no recorded date last. retired_at, from and reason are read from the root log.md (the only place concept_archive keeps them, kb.Retirements), so a status set by hand has none; superseded_by is omitted when the caller cannot see that id, and so are from and reason (free text that names where the page came from) when the caller cannot see the source. An archived page later superseded keeps both stories: from and reason from the archive, retired_at from the entry that set its current status. maps — each visible collection {name, title, kind, archive, retired, total}, the counts being its top-level concepts. journals — each visible journal {name, title, harvest_after, waiting, archived}: waiting is its harvest_candidate items in kb_review, archived its concepts with status archived. Filtered with the caller's principal, never a whole-KB resource.
GET /api/ui/v1/kbs/{kb}/changes?since=&limit= changes_since's own response for the caller: concepts changed since since (default 7d), each with authors, operations and recorded reasons. limit defaults to 50, clamped to 500.
GET /api/ui/v1/kbs/{kb}/work?scope=&include=&stale=&where=&order_by=&fields=&limit=&offset= work_list's own response for the caller's principal (D302); where repeats, order_by and fields repeat or take a comma list. 404 for a KB the caller cannot see.
GET /api/ui/v1/kbs/{kb}/maintenance/summary What keeps the KB in repair (D323): auto_repair (enabled and default always true, checks the safe set, interval_days the constant 1), last_auto_repair (the newest line of the background log, or null; the background repair runs at every start, after a pull that moved HEAD and once per interval, D704), last_doctor, next_doctor, doctor_interval_days (the fixed interval), runs (D365: the background repair's runs of the last 30 days from its log, newest first, at most 50, each like last_auto_repair), and repairs: the kb_repair and auto-repair commits of the last 30 days, newest first (at most 50), each {sha, at, subject, reason, files, background, revert} where revert is the exact cartographer kb repair <kb> --revert <sha7> command. A whole-KB resource: 404 for a caller that cannot see the whole KB.
GET /api/ui/v1/kbs/{kb}/maintenance/review The kb_review items the caller can see, filtered as the tool filters them, without the lint_judgement ones (lint findings, listed by /lint) and without a template proposal's template/templates text and map_update arguments (D704): {items: [{kind, concepts, evidence, suggested_action, term, template_slug, weight}]}.
GET /api/ui/v1/kbs/{kb}/status kb_status's response (open knowledge gaps, search misses, stale count and the rest). A whole-KB resource like the artifact routes: 404 for a caller that cannot see the whole KB.
GET /api/ui/v1/kbs/{kb}/checks {categories, checks} (D365): every lint check the server runs — kb_review kinds excluded — in registry order, each {name, category, fixable, auto, active, reason?}: fixable when the check has a mechanical fix, auto when this KB's background repair applies it by itself, active: false with a short reason when the check cannot run on this KB (nothing to compare against, or every map able to run it opted out, D371; docs/data-plane.md §Active checks). The categories are the registry's (lint.Categories), in reading order. Counts are not here: they come from /lint, so a scoped or narrowed caller sees the coverage of exactly its findings.
GET /api/ui/v1/kbs/{kb}/births {births: {id: RFC3339}} (D367): when each visible concept entered the KB, the commit that first added its file, carried across renames and forgotten on deletion; a concept not committed yet is absent. Read from the whole git history on each call; the Atlas asks for it once, when the growth replay starts.
GET /api/ui/v1/kbs/{kb}/revision {revision}: an opaque token that changes whenever a concept file is added, removed or edited — by the server or out of band (a pull, an editor) — and on a server restart. The Atlas polls it to reload in place (D337). A whole-KB resource like /status: 404 for a caller that cannot see the whole KB.
GET /api/ui/v1/kbs/{kb}/lint?scope= Every finding (a finding is a problem, D947: no floor, no severity), in the registry's category order, plus the totals by check. Each finding carries handler (D365): auto when the background repair will fix it — it has a fix of a check the heartbeat runs unattended (AutoRepairSafe) — else doctor. A caller that cannot see the whole KB gets no link_to_retired, index_lists_retired, unknown_type or value_case_variant finding: they are computed on the whole graph or name the whole KB's types and values and would disclose hidden concepts (D243).
GET /api/ui/v1/kbs/{kb}/artifacts What artifact_list lists, sorted by kind then name, each with description (a skill's or agent's frontmatter, a template's title), content_hash and signed (manifest artifacts only), clients (from the sync destination matrix), concepts (the existing concepts its files reference as [[id]] or a written-out id — never guessed from names, D286) and its files' path, sha256, size and executable bit; counts per kind; kinds (D864), every kind a KB can hold in reading order (skill, agent, hook, mcp, instructions, template; secrets answer from /secrets), each {kind, title, kb_path, doc, kb_only, count, clients} from provisioning.Kinds — kb_path is where the kind's files go in the KB (skills/<name>/SKILL.md), doc a repository path with an anchor (docs/sync.md#agents-and-hooks, resolved by the UI, never by the server), kb_only true for what no client receives, count the artifacts of the kind (0 when the KB has none) and clients the {id, name} that receive one, empty for a KB-only kind; issues, the skills left out for failing validation (D238).
GET /api/ui/v1/kbs/{kb}/artifact?kind=&name= One artifact as above, each file with its UTF-8 content, or binary: true / truncated: true (over 256 KiB) instead. Nothing is decrypted.
GET /api/ui/v1/kbs/{kb}/secrets {files, kind} (D708, D864): kind is the secrets descriptor {kind, title, kb_path, doc, kb_only}, the same shape as one entry of the artifacts route's kinds without count and clients, so an empty panel can say where a file goes; files lists each secrets/*.sops.yaml regular file (1 MiB at most) with path, entries (pointer as RFC 6901, aad_path — the additional data sops sealed the value with —, the raw enc string and referenced_by, the concepts whose secret_refs name the pointer), recipients, age_stanzas (the data key wrapped for each age recipient), lastmodified, source_of (concepts declaring the whole file as secrets_source) and error when the file is not a sops document. Ciphertext only: the server decrypts nothing. A whole-KB resource like the artifact routes; GET /kbs says per KB whether the principal may read it (secrets).
GET /api/ui/v1/kbs/{kb}/placeholders {registry, keys}: the {{repo:…}}/{{path:…}} vocabulary (D263), sorted by kind then key. Each key is {kind, key, description?, default?, remote?, declared, concepts, artifacts, conflicts?}: declared from paths.yaml, concepts the visible concepts citing it, artifacts the KB's artifacts (kind/name) citing it, conflicts the other KBs the caller sees whole that declare the same key with another default. Cited-but-undeclared keys have declared: false. paths.yaml and artifacts are whole-KB resources (the sync_pull class): a narrowed caller gets registry: false and only the keys its visible concepts cite, undeclared, with no artifacts or conflicts.
GET /api/ui/v1/kbs/{kb}/contradictions?status= {contradictions}: the Contradiction concepts that are not knowledge gaps, as contradiction_report lists them (id, title, involves, contradiction_kind, resolution_status), filtered to what the caller sees. status defaults to open; all lists every status.
GET /api/ui/v1/kbs/{kb}/maps {maps}: map_list's own entries for the caller, each with the map's whole contract (kind, ontology_mode, concept_types, stale_after and stale_after_defaulted, required_fields, field_values, legacy_keys, and the keys listed under map_list); [] when the KB has none.
GET /api/ui/v1/kbs/{kb}/history?id=&limit= concept_history's own response (id, revisions newest first, truncated, note when the KB keeps no git history). id is required; 404 for a concept that is missing or that the caller cannot see; limit defaults to 20, clamped to 100.
GET /api/ui/v1/kbs/{kb}/journal?n= {text}: the last n entries (default 20, at most 200) of the root log.md as markdown, as log_tail returns them. A whole-KB resource: 404 for a caller that cannot see the whole KB.
GET /api/ui/v1/kbs/{kb}/sources?status=&limit= {sources}: source_list's own entries, oldest first (id, title, source_kind, ingest_status, timestamp, cited_by, cited_by_ids), filtered to what the caller sees. status is pending (default), ingested, skipped or *.
GET /api/ui/v1/kbs/{kb}/services {services} (D818): the concepts of type Service (id, title, status), each filtered with the caller's principal. Not a relay of service_list, which lists every Service of the KB.
GET /api/ui/v1/kbs/{kb}/conflicts conflicts_list's own array (concept_id, path, files, kind, detected_at, resolution_strategy), [] when there are none. The registry names files KB-wide: 404 for a caller that cannot see the whole KB.
GET /api/ui/v1/kbs/{kb}/audit?limit= {enabled, state, entries}: the newest audit entries for this KB, newest first (at, phase, principal, tool, outcome, commit_sha, …), limit defaults to 100, at most 500; enabled: false when the server runs without one (a best_effort log that could not be opened, D905). The log names other principals: admin only, 404 for anyone else.
GET /api/ui/v1/whoami {id, admin, permissions}: the principal behind the request and its own policy rules (kb, write, maps, journals, types), narrowed to the KBs this server mounts. Nothing in it is anyone else's, so no extra scope; a KB-less route like /kbs and /settings.
GET /api/ui/v1/kbs/{kb}/assets?id= {id, assets}: asset_list for an expanded concept (path, size, sha256, executable, oversized?). id is required; 404 for a missing or invisible concept, 400 for one that is not expanded.
GET /api/ui/v1/settings The Settings page's data (D743): version, latest_version and update_channel when known, server (every server-level schema key from internal/config/schema.go) and kbs (one section per KB the caller can see, with the kbs[] keys and, for a caller that sees the whole KB, git: remote, sync and push state). plus server_yaml (the server's configuration as the YAML file it amounts to, commented by the schema, its kbs[] entries limited to the KBs the caller sees), a yaml per KB section (the same for that KB's own keys) and clients (D818): the caller's own client settings reports (§Client settings reports), [] when none, never another principal's, each yaml rendered by this server's client schema when the report arrived (the keys it sets documented, the others listed commented out; §Client settings reports), and client_template (D948): {entries, yaml}, every client key at its default and the file they amount to, which the Client tab shows before any client of the caller has reported. Each entry is {key, value, origin, description, default, type?, values?, env?, flag?, restart?, secret?} (type is the kind of value, values the accepted ones in free text, both from the schema); origin is default, file, env or flag, recorded by the loaders. A secret's value is "(set)" or "(unset)", never the value. A remote URL is shown without its userinfo (the redaction kb_status applies), in the entries and in both YAML views, for every caller. Server-wide values are an admin's: a caller that is not an admin gets in server only update_check, web.enabled, kbs and git.identities (limited to the KBs it sees), and no server_yaml; the paths (data, credentials_dir, audit.*), auth.*, http and git.identity are absent. Same auth as the other routes, no new role.
GET /api/ui/v1/releases The release history behind the version in the Atlas (D862): {current, latest?, channel?, upgrade_command?, releases, stale?, unavailable?, releases_url}. releases is newest first, each {version, name?, url, published_at, body, truncated, prerelease, current} (version without the leading v, url the release page on GitHub, "" unless it is an https URL, body the release's Markdown changelog, cut at 64 KiB with truncated, current for the release this server runs). A release whose tag is not a version (updatecheck.ValidTag: MAJOR.MINOR.PATCH with an optional pre-release) is left out of the list, and so is a release candidate (a tag with a pre-release suffix, v0.28.0-rc.20261111, D1192); latest is never such a tag. latest is the latest_version /health reports; upgrade_command is the install channel's command (updatecheck.Channel.UpgradeCommand), present only when latest is and the channel names one. The server reads the GitHub release list on demand (up to 3 pages of 100), keeps it in memory for an hour with the ETag, and answers stale: true with the last good list when a refresh fails. unavailable is "disabled" when the update check is off (update_check, CARTOGRAPHER_NO_UPDATE_CHECK, a dev build: no call is made) and "unreachable" when GitHub could not be reached and there is no earlier list; releases is [] then, and releases_url (the GitHub release page) is always present. The same for every caller who can open the Atlas: nothing in it is KB data. A KB-less route like /settings.

The two artifact routes also carry last_used, last_used_provider and last_used_days_ago (all null for an artifact no client reported; last_used_catalog_only: true when the only signal is a catalogue load), which the panel renders as a "Last used" column (D326).

Every route is GET/HEAD only; anything else is 405 with Allow. A malformed parameter is 400 with {"error": {code, message, field}}; an unknown or invisible KB or concept is 404 with the same envelope, never 403. The two artifact routes answer 404 unless the principal can see the whole KB: artifacts are whole-KB resources. Responses carry Cache-Control: no-store, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer.

Usage reports (POST /api/usage, D326)

Which skills and agents the clients actually load is invisible to the server: a skill reaches a client as a file sync materialised, and the client reads it from its own disk. So the client measures it and reports (docs/sync.md §Usage scan). The route is client-to-server metadata, not an agent operation, hence HTTP and not an MCP tool; it is routed with or without the web UI and sits in the same auth chain as /mcp.

POST /api/usage?kb=<name> takes a JSON array of {name, kind, provider, source, last_used, count} (kind is skill or agent; at most 1 MiB) and answers {received, merged}. The merge is per (artifact, provider): the newer last_used replaces the stored row and count is replaced, never summed (the client already aggregated its whole window). A bundled artifact, a row with no last_used, an unknown kind are dropped; a last_used more than a day ahead is clamped to now. It needs the whole KB in write scope; any other caller gets the 404 of an unknown KB (no existence oracle), a missing token 401, a non-POST 405. The store is .cartographer/usage.json (docs/data-plane.md), which the artifact_unused lint check reads (D1043).

Worker heartbeat (POST /api/workers, D1259)

A cartographer worker process declares itself once a minute. Like the usage report and the client settings report it is client-to-server metadata: an HTTP route inside the auth chain of /mcp, always routed (with or without the web UI), never an MCP tool (a tool would spend tools/list budget on every client for something no agent calls, D285). The declaration is a hint, never trusted beyond display: validated, bounded in time and ignored when stale (D369); the server never acts on it, the budget is enforced by the worker.

Body (one JSON object, application/json, 64 KiB cap): worker (this process, 1-64 of [a-z0-9._-]), perimeter (1-40 of [a-z0-9-], shared by replicas), version, kbs (1-32 names), state (idle, running, waiting, budget_exhausted, stopped), interval_seconds (clamped to 15-3600, default 60), last_run ({at, units_done, tokens_spent, commits}), budgets (at most 8 {profile, tier, spent, limit}, one per model profile, never summed) and next_window_at (omitted when past or more than 8 days ahead). A last_run.at more than a day ahead is clamped to now; counters are clamped to 0..2^40; text is truncated and stripped of control characters. received_at is the server's clock.

State is in memory (a restart forgets it until the next beat), at most 16 workers per principal and 256 in total, the oldest evicted. A declaration is live while less than three intervals old; state: stopped removes it at once. The declared KBs are filtered to those the principal can write; none left answers the 404 of an unknown KB (no existence oracle). Errors: 405 (+ Allow), 401, 415, 413, 400.

The answer is {accepted, kbs: {<kb>: {last_activity_at, idle_seconds}}, activity_known, server_started_at, now}. last_activity_at is the last authorized MCP tool call on that KB by a principal that has no live worker declaration, other than sync_pull (the 30-minute sync timer would otherwise keep every connected KB active), counted in callToolChecked after authorization: a denied call is not activity, nor is the Atlas UI API. It is null (and idle_seconds absent) until a call happens after the process started; the worker then reads "idle since server_started_at". now is the server's clock, so the worker never compares its own. activity_known is false when authentication is off: every caller is the local admin and the server cannot tell the worker from a person.

Client settings reports (POST /api/client-settings, D818)

The Settings page shows the viewer their own client beside the server and the KBs, and only the client knows its .cartographer.yaml and the schema of the version it runs. So cartographer sync reports it after every apply (docs/sync.md §Client settings report). Like /api/usage it is client-to-server metadata: an HTTP route in the same auth chain as /mcp, with or without the web UI, not an MCP tool.

The body is one JSON object (at most 256 KiB): machine (required), version, server_url, entries (the client schema keys in the shape of the settings entries above), yaml (the file as it is on disk, "" when there is none), providers (per connected agent client: name, scope provider or workspace, entry bridge or http (D737, the kind of global MCP entry), isolation workspace for the bridge or none, and workspaces, each {path, remote?, kbs}, listed whatever the artifact scope) and placeholders (each {key, kbs?, path?, …}: where the {{repo:…}}/{{path:…}} keys of the connected KBs resolve on that machine). The answer is {received: true}; an unreadable body or one without machine is 400, a larger one 413, a non-POST 405.

The server keeps the report in memory, per principal and machine, at most 8 machines per principal (the oldest goes first), and returns it only to the principal that sent it, as clients of GET /api/ui/v1/settings; no list of clients is exposed. A restart forgets them until each client's next sync. Nothing is written to the KB.

The server prepares a report once, on receipt, before it keeps it (scrubClientReport in internal/mcpserver/uiapi_clientsettings.go), so a read costs what was posted and the raw secret never stays in memory:

  • Secrets. The token key is a secret in the client schema (Secret: true). Its entry's value is "(set)" (the file sets a non-empty one) or "(unset)", and its value in yaml is <redacted>, as in the server's own server_yaml: a literal token and a ${NAME} reference alike, whichever way the file writes it (quoted, flow, block, anchored, repeated), and the same for a commented-out # token: … line. A file where a secret escapes that mask (a YAML merge key <<:, a second document) is not shown: yaml holds one line saying so. The client masks them too (clientconfig.Report); the server does it again for a client that predates this. A file that is not YAML cannot be checked for a secret and is not kept (yaml is "").
  • Rendering. yaml is rendered against the client schema (settingschema.Schema.Format) once, here, not on every GET /settings. A file that repeats a key is documented once per occurrence, so its rendering can be tens of times the file: when the rendering is more than twice the (masked) file plus the fixed documentation of the unset keys, the masked file is kept as it is.

Search index

Rebuildable index (vault = truth, index = disposable), with two persistence levels:

  • In-memory (default/Core): a pure-Go keyword inverted index (internal/search). Rebuilt on every startup by walking the concepts. Tokens are lowercased and diacritic-folded ("attivita" finds "attività"; ß, æ, œ stay as they are), and weighted like the SQLite columns: title tokens count 5×, frontmatter tokens 2×, body tokens once (D246); the text of the concept's assets counts like the body (D277). Snippets are cut from the text as written, accents included.
  • Centrality prior (D251): both backends' text scores are adjusted the same way before the final sort and cut: score = text × (1 + 0.2 · p), where p is the concept's PageRank percentile among the concepts the caller can see (the share of the others with a strictly lower PageRank). A concept nothing links to has p = 0 and keeps its text score exactly; the boost is at most +20%, enough to reorder near-ties and too small to lift a weak match over a strong one. The backend returns a window of min(3 × limit, 100) candidates (never fewer than limit) so a central concept just below the page can enter it. For a narrowed principal the percentiles come from its visible graph, so a hidden hub never moves the order; the whole-KB percentiles are cached per link-graph generation. graph_context seeds from the same ranking. The reported score is the boosted one.
  • Read-access log (D322, D778): .cartographer/read-access.json (local, never committed, not in the audit trail) holds {"since": RFC3339, "read": {"id": RFC3339}, "surfaced": {"id": RFC3339}}. Only concept_read stamps read: the content reached the agent. Every search hit stamps surfaced: a pointer the agent may never follow, so never a read. Navigation tools (graph_*, atlas_overview, concept_list) stamp neither; there is no per-client breakdown. The file is written when a record finds it older than five minutes, so a crash loses at most that window. since is when read began counting: a concept with no read entry was not read since then — "unknown" before it, never "never read". A file in the first, flat shape ({"id": RFC3339}, reads and hits mixed) is migrated on load: its entries become surfaced, so none is taken for a read, and since restarts.
  • Miss log (D247): a search that returns zero results — after the any-term pass — for a principal who can see the whole KB is appended to .cartographer/search-misses.jsonl (local, never committed): the time and the query, trimmed, lowercased, diacritic-folded and whitespace-collapsed. A narrowed principal's misses are never recorded: for them zero hits may only mean "hidden", and recording would show their queries to whole-KB readers. The file is rewritten to its last 2,000 lines past 2,500; a write failure is logged and never fails the search. A call with record_miss: false is never recorded (D319). kb_status reports it as search_misses, re-running each reported query against the in-memory index (reconciled first; never FTS5, which may be absent) and marking resolved the ones that now find something: a filled gap closes by itself, with no dismiss list and no edit to the log.
  • Freshness by validation (D245): no write path updates an index. Before every search (and graph_context's seeding), after every git pull and on reindex, the link-graph cache (D241) validates the concept files — a stat walk, zero file reads when nothing changed — and reports every concept whose resolved content changed, appeared or vanished since the last reconciliation; one per-KB reconciler applies that delta to the in-memory index and, when present, to SQLite. A write handler, a move, a conflict resolution, a pull or an operator's editor are seen the same way, with or without git and with or without SQLite. Assets are not Markdown, so the same reconciliation compares, per expanded concept, a signature of its indexable assets (path, size, mtime; no content is read) with the last one and re-adds the owner when it differs; a signature whose mtime is inside the graph cache's racy window is redone once more after it. The SQLite hash of a concept with indexable assets is its content hash plus a digest of that signature, so an asset-only change is seen after a restart; a concept without them keeps its plain content hash.
  • Persisted SQLite (internal/sqlindex, D32): when the KB has an openable .cartographer/index.db, keyword search uses FTS5 with a trigram tokenizer (supports substrings, not just whole words) and remove_diacritics 1. The table has four indexed columns — title (frontmatter title), meta (the raw frontmatter), body (the body) — plus assets (the text of the concept's assets, D277), ranked by bm25 with weights 5 / 2 / 1 / 1, so a concept named after the query beats pages that mention it in passing (D246). A second FTS table, sections_fts(id, heading, title, body) (same tokenizer, bm25 weights 3 / 1, schema version 4, D1207), holds one row per ## section of each body and only answers which section of an already ranked page matched best (Index.BestSection, the query's tokens OR-ed): it never ranks pages. Writes of a reconciliation go through Index.Batch, one transaction per 100 concepts, because FTS5 writes a segment at every commit and at every statement savepoint: a cold rebuild of a few hundred concepts is bound by that, not by tokenizing (#1329). The layout is versioned with PRAGMA user_version: opening an older database drops both FTS tables and empties concepts, and the next reconciliation reindexes every concept — once, automatically. Multi-term matching (two or more terms) tries all terms first and also runs the any-term pass when fewer than search.OrFallbackFloor (3) allowed full matches exist (D343): the OR-only hits follow every full match, each with partial: true, and the response carries or_fallback: true and a note (the in-memory index does the same); terms shorter than three characters are omitted from the FTS match. At boot the persisted hashes are reconciled with the files (this also drops rows for concepts removed while the server was down); while running, the D245 reconciler keeps the table in line, skipping an upsert whose hash SQLite already stores; reindex(full: true) rebuilds every concept instead of diffing (D136). On this path, the search response's mode field is keyword_fts5. An embeddings table in an existing index.db is never read nor written, and needs no migration. Best-effort: if the DB can't be opened or FTS5 is unavailable, it degrades to the in-memory path.
  • Default answer size (D1185, D1354): a search without limit returns up to 20 hits and a related block of 5. D1185 had cut it to 8 hits and no block (about 2.2 KB on real sessions against 4.2 KB) and a benchmark showed answer quality dropping with it (#1354), so D1354 restored the v0.26 default, keeping the compact JSON; TestSearchDefaultAnswerBudget holds the average of its fixture under 5 KB (testing.md). The Atlas endpoint keeps its own limit and sends related: 0.

At small scale index.md and keyword search can be enough: keyword search is the only search mode the server offers (D135).

Validation and invariants

  • validate checks parseable frontmatter, a non-empty type, reserved files, layout/depth rules and the allowed type palette of strict Maps.
  • lint reports deterministic findings. It does not generate typed graph edges or Contradiction concepts.
  • Conformance findings (D289) are nonstandard_field and tool_param_field; see data-plane.md §Conformance checks. Like every finding they fail gate_check (D947).
  • Contract findings are missing_required_field, invalid_field_value, forbidden_field, index_incomplete and contract_malformed. A lint finding makes gate_check fail (D947); commit_gate remains contradiction-only.
  • When the KB has a paths.yaml (D263), a concept citing an undeclared placeholder key is unknown_placeholder, a declared key nothing cites is unused_placeholder, and a malformed entry is contract_malformed on paths.yaml; see data-plane.md.
  • When the KB has a glossary.yaml (D276), a concept using a forbidden term in prose (outside code) is forbidden_term, and a malformed entry is contract_malformed on glossary.yaml.
  • commit_gate inspects existing type: Contradiction concepts with resolution_status: open and blocks a supplied set of changed concept IDs when they are involved, except knowledge gaps (contradiction_kind: missing_context / open_question, D273), which never block.
  • Broken links are tolerated on write and surfaced by lint — including a dead link in a map's index.md (D229); completeness is checked on every curated index (index_incomplete, D907).
  • A concept with no (or a blank) title is missing_title: the title is the label concept_list, search results and indexes show, while validate only requires type. The message suggests the first H1, and the fix (set_value title, D357) is the heading or, failing that, the humanized file name. When both exist and differ it is title_h1_mismatch (fix sync_h1, or set_value title when the title is the one title_quality rejects and the heading is not, D357), and a title that is an emoji, a sentence or carries a status is title_quality (D315).

Provenance, history and audit

Provenance and citation sections are KB conventions; the server does not require them on every concept. Every concept write appends its own log.md line (asset and artifact writes do not, §Read/write boundary) and log_append adds a narrative entry. Git commits are the durable change history.

internal/audit implements a JSONL hash-chain with Ed25519 signatures. The log is always on (D905): every tools/call appends an attempt event before dispatch and a completion event after it (D119), so the log is a complete operational audit trail: semantics, failure modes and the cartographer audit verify|export commands are in transport-auth.md §Operational audit.

The content hash is computed on normalized content and per section to avoid spurious stale_write failures.