Skip to content

Client ↔ provisioning synchronization

How artifacts served by the server (skills, agents, hooks, instructions, mcp) reach every client provider in the registry (internal/configurator/registry.go) and stay in sync. Package internal/provisioning; MCP tools sync_check/sync_apply/sync_pull; CLI cartographer status/sync/resolve. Rationale: synchronization/provisioning decisions and client decisions.

The problem

The artifacts a client must have come from two sources that change over time:

Source Where it lives When it changes
Server bundle internal/skillbundle/bundled/ (compiled into the binary) — skill only Cartographer upgrade
KB artifacts skills/, agents/, hooks/, mcp/ at the KB root (git) git pull, skill_install, edits

The bundled skill catalog is the directories of internal/skillbundle/bundled/ (server and client operations, KB creation, import, ingest and doctor, conflict resolution, product feedback). Because the bundle is compiled into the binary, clients receive operational guidance that matches their installed Cartographer version after sync.

Without a sync mechanism, the files materialized on the client silently drift on every bundle upgrade or KB git pull. Goal: the client notices the change and realigns, with a model extensible to new kinds without a redesign.

Core concepts

1. Provisioning artifact (provisioning.Artifact)

The generic unit of what gets synchronized: kind (skill/agent/hook/instructions/mcp, extensible), name, source (bundle | kb:<name>), version, content_hash, signed. The per-provider destination is resolved by destDir(kind, name, provider). A new future kind = a new emitter in BuildManifest + a case in destDir: manifest, lockfile, revision, detection, triggers, and pruning don't change.

A KB's provisioning artifacts are edited through two equivalent channels: git (clone/push of the KB repo, as usual) or the MCP tools artifact_read/artifact_write/artifact_list/artifact_delete (→ control-plane.md §KB-root artifacts; requires a token with write scope on the KB). The MCP write path goes through gitWrap (lock, commit, SyncIn/SyncOut), so it also absorbs concurrent git pushes; the manifest revision changes with the content on disk, and the layered triggers propagate to clients either way. templates/<slug>.md is intentionally a KB-only artifact: it uses the same artifact lifecycle but is excluded from BuildManifest, the kind × provider matrix, lockfiles, pruning and manifest revisions.

2. Provisioning manifest — source of truth

Generated by the server (BuildManifest, which knows the bundle + all mounted KBs): the complete set of what the client should have. A KB skill, agent, hook or MCP artifact is built only from the files git tracks in the KB clone (git ls-files, internal/provisioning/kbtracked.go): an untracked or git-ignored file, such as a __pycache__ left by running a skill's script, is neither shipped nor hashed, so it cannot change the revision; a KB that is not a git clone is not filtered (#1325). The manifest carries an aggregate revision — the hash of the ordered list of content_hashes (+ kind+name+source). A single changed artifact changes the revision: drift is detected by comparing a single hash.

3. Client lockfile — applied state

.cartographer-sync.lock.json next to the generated configs, in v2 multi-provider format (provisioning.LockFile): one Lock per provider (applied_revision + managed[], the files Cartographer itself created). The old v1 single-provider format is read and automatically migrated by ReadLockFile.

Optional per-provider fields, all meaning "unchanged behaviour" when absent: base_dir — the directory managed[].path is relative to, recorded only for a workspace projection (its repository); a lockfile written for a client since removed can still carry one for a global section, which sync drops (D898); server_version — the Cartographer server version this state was materialized against (D142), which sync compares with the live one to report that the server changed, and which a sync that cannot reach /health leaves untouched rather than erasing; project_scope — the lock's managed paths were resolved through the project-local half of the destination matrix (§Project-local destinations); session_hook_limit_acked — connect has shown the provider's session-hook limit (§Scheduled trigger). Each managed[] entry may carry executable_paths — the artifact-relative paths its files were written executable at, which is what lets a client whose filesystem has no execute bit recompute materialized_hash at all (see §Executable flag).

Drift = manifest.revision ≠ lockfile.providers[<provider>].applied_revision. managed[] enables pruning: sync only removes what it created, never user files.

Detection and triggers — layered model

Layer 1 — SessionStart bootstrap hook (primary trigger)

Hook cartographer-bootstrap (reserved name, provisioning.BootstrapHookName): generated entirely by the client (never from the manifest), installed/updated by EnsureBootstrapHook on every connect/sync and removed by disconnect. At the start of a session, the generated script (deterministic, silent, always exit 0) checks that cartographer is resolvable and runs cartographer sync silently (output discarded). The script is the platform's own: bootstrap.sh (#!/bin/sh, command -v) on unix, bootstrap.cmd (batch, where) on Windows, where a shell script is not executable in any sense the operating system recognises and the explicit chmod that follows the write grants nothing (D216). Explicitly excluded from ComputeDiff/Apply (a server manifest will never contain it → it's never treated as an orphan to prune). Details → D60.

Write-findings hook (cartographer-write-findings, D353). A second client-generated hook of the same family, reserved name provisioning.WriteFindingsHookName, installed by EnsureWriteFindingsHook next to the bootstrap hook on every connect/sync and removed by disconnect; it goes through the same no-follow writers and registration code (ensureSyntheticHook) and is excluded from ComputeDiff/Apply the same way (reservedHookNames). Claude Code, Codex, Copilot CLI and OpenCode 2.x (D361, D362, D676): a PostToolUse entry whose matcher is mcp__.*__(concept_write|concept_new|concept_patch|concept_batch|concept_move|concept_archive|supersede|index_patch)$ (the server segment is a wildcard, so it follows server_name) runs a two-line script that hands the payload on stdin to cartographer hook write-findings (configurator). When the write response carries findings, the subcommand returns them to the agent as feedback it must act on (fix the page in the next write: nothing accepts or silences a finding, D907); it cannot undo the write. The channel is per client, chosen by the hook command and never sniffed from the payload: Claude Code gets stderr with exit 2; Codex gets --channel context (provisioning.writeFindingsCommandArgs), hookSpecificOutput.additionalContext on stdout with exit 0, because on exit 2 Codex replaces the tool result with the stderr text and the agent repeats the write (D361); Copilot CLI gets --channel copilot, a bare {"additionalContext": …} on stdout with exit 0, and its entry has no matcher (the hook fires for every tool and the command filters on the tool name). In every other case, a crash included, it is silent and exits 0. The shim forwards its arguments ("$@" / %*). OpenCode ≥ 2 gets a dedicated generated plugin instead of the generic one (D362): on execute.after it pipes {tool_name, tool_response} to the same shim and, on exit 2, appends the stderr text to result.output; it never throws, because a throw reaches the model as a tool error and the agent retries the write. OpenCode 1.x (or an unreadable version) gets no hook and loses a previous one. Codex trusts a hook per definition, so the user approves this hook once in Codex, as for the bootstrap hook. The tool list is provisioning.WriteFindingsTools, checked by a test against the handlers that return findings. It is always installed (D906): there is no per-machine opt-out. Other clients are added by a probe of their payload and feedback channel, never by inference.

After the silent sync the script runs cartographer update notice, keeping its stdout and discarding its stderr (D254). It prints nothing unless a newer release is known, then one paragraph that a client adding SessionStart hook output to the session context (Claude Code) hands to the agent; the rest of the script's guarantees are unchanged. Because the hook is excluded from diffing, nothing compares an installed script with the current one: what delivers a changed script to an already-connected machine is that EnsureBootstrapHook rewrites both files unconditionally on every connect/sync, and the hook itself runs sync — so the first session start after an upgrade installs the new script and the next one runs it, with no reconnect. A test pins that rewrite.

Provider Hook point Registration
claude SessionStart entry in ~/.claude/settings.json
codex SessionStart entry in ~/.codex/hooks.json (D230)
opencode session.created event generated plugin in ~/.config/opencode/plugins/
kiro SessionStart, only in kiro-cli chat --v3 --tui (D300): plain kiro-cli chat and every --no-interactive run fire no standalone hook entry in ~/.kiro/hooks/cartographer.json, plus the scheduled trigger below for every other session
antigravity SessionStart (D1007) owned cartographer-<name> definition in ~/.gemini/config/hooks.json
copilot sessionStart (D676) ~/.copilot/hooks/cartographer-<name>.json, the hook's own files under ~/.copilot/cartographer-hooks/
crush none: only PreToolUse fires (D363) the scheduled trigger below

Scheduled trigger (D140). For a client with no session hook, cartographer service sync-timer install [--interval 30m] registers a launchd agent (macOS), a systemd user timer (Linux) or the Windows Scheduled Task \Cartographer\Sync that runs cartographer sync on an interval. setup and connect (and so reconnect) install it themselves (D325) when a connected provider has no session hook, or one that fires only in some of its sessions (kiro, provisioning.SessionHookLimit, D300), and the timer is not already installed; setup shows it in its plan first. service sync-timer uninstall is the explicit opt-out and is remembered (sync_timer_opt_out in the client state file, .cartographer-state.json): connect then only notes it, and doctor's trigger check drops to info; sync-timer install clears it. disconnect removes the timer once no remaining client needs it, without recording an opt-out. status never installs: it names the command. The predicate is the one doctor's trigger check uses, so the commands cannot disagree about whether a trigger covers the client. A timer status that cannot be read is not evidence of coverage, and a client config that cannot be read never triggers an install. For a client whose hook fires only in some sessions, connect and reconnect always print the hint and then record in the lockfile that it was shown (session_hook_limit_acked, carried over by every sync); status does not repeat it after that, while a client with no hook at all is always named and doctor's trigger check still reports the limit on every run (D320). The timer runs plain sync: sync has no trust flag (D906; the one explicit trust write is workspace bind codex … --trust, D1074, never run by a timer), and an MCP descriptor, a hook or an executable file still needs the point approval an unattended job cannot give (D1141). Logs: ~/Library/Logs/cartographer/sync.log on macOS, the journal on Linux, %LOCALAPPDATA%\cartographer\Logs\sync.log on Windows (configurator §cartographer service sync-timer).

Beyond the revision comparison, every sync also verifies the managed files on disk and restores what diverged — see §On-disk verification and healing.

Layer 2 — On-demand MCP tools

Tool What it does
sync_check Returns manifest.revision, status (in-sync/drift), and the diff against the supplied lockfile. Read-only.
sync_apply Materializes into base_dir shared with the server's filesystem (local/stdio deployment), updates the lockfile, prunes. Honors the signature gate. Supports dry_run. Refused, dry_run included, to every caller but the local operator (stdio, or HTTP with authentication off): a token never reaches the server's filesystem.
sync_pull Returns the manifest with file contents in base64: used by the remote HTTP client, which does not share the filesystem with the server. Also lists the path placeholders the KB cites (placeholders, outside revision and unsigned — §Path portability placeholders). Read-only.

Remote HTTP client (cartographer connect/sync/status)

When client and server don't share a filesystem (internal/client, internal/clientconfig, cmd/cartographer/clientsync.go):

  1. the client calls sync_pull once per KB in the union of every connected provider's binding (§Per-provider projection) and keeps the responses unmerged. The calls run concurrently, bounded by maxConcurrentPulls (cmd/cartographer/clientsync.go), because each can wait on that KB's server-side fetch (control-plane.md, D93); the responses are then checked in KB order, so the error a failing run reports is the first failing KB in that order, whichever failed first in time;
  2. for each provider it selects its bound KBs' responses (SelectForSources), merges them with provisioning.MergeArtifactsStrict — which refuses a kind+name claimed by two of that provider's KBs — and verifies signatures;
  3. it reconstructs each artifact hash from received paths, bytes and executable modes, then verifies any detached signature against the local KB pin;
  4. Apply materializes/prunes and writes the v2 lockfile, one entry per provider;
  5. pruning remains managed-only.

Each sync_pull call (and the equivalent cartographer reindex remote call) is qualified with that KB's tool-name prefix (D102), discovered from a live /health snapshot rather than re-derived client-side (D120: resolveKBTargets/qualifyTool in cmd/cartographer/multikb.go). This keeps the client plumbing correct whether the server prefixes tools or not — an unprefixed KB is called unchanged, exactly as before D120.

A native local upgrade is another trigger for the same path: after install.sh or install.ps1 replaces the binary, cartographer upgrade-repair reconciles the configured providers in place (D121, → deployment §Upgrades, schema migration, and repo growth). After a Homebrew upgrade — which runs no Cartographer code — the next plain cartographer sync does it instead (D199): before syncing it replaces a native service still running the previous binary, under the client-state lock so concurrent session-start syncs replace it once. It runs the very same in-process sync as plain cartographer sync — same authorization, pinned keys and point approvals. Automatic repair never invents an approval and never broadens trust; it also never disconnects or reconnects a provider. It only runs when the client is configured against that same native service over loopback HTTP; any other endpoint is skipped rather than contacted.

Usage scan (D326)

After the apply, cartographer sync (not a dry run, only with the server reachable) tells each server which of its skills and agents this machine's clients loaded. The server cannot see it: the client loads a skill from the file sync wrote. So provisioning.ScanUsage (internal/provisioning/usage.go) reads the clients' own session transcripts, read-only (D148), for the paths the lockfile says it materialised, and cmd/cartographer/usagesync.go posts only the aggregate, per source KB, to POST /api/usage (docs/control-plane.md): {name, kind, provider, source, last_used, count}. Never a prompt, a message, a session id or a file content.

What is read: only structured tool-call records, never a user record, assistant text or reasoning. A skill counts as used when a tool call names its materialised path (a file read, a shell cat, a grep), never when its name merely appears in prose; plus, where the client has one, an explicit activation.

Provider Transcript Signal
claude ~/.claude/projects/**/*.jsonl Skill tool, Agent/Task subagent_type, tool call carrying a materialised path
kiro ~/.kiro/sessions/**/*.jsonl kind.BuiltIn.FileRead of the path, or a shell command containing it
codex ~/.codex/sessions/**/rollout-*.jsonl partial: a catalogue-loaded timestamp (count 0) plus path-bearing tool calls
copilot ~/.copilot/session-state/*/events.jsonl tool.execution_start with toolName: "skill" and arguments.skill (an activation); MCP calls and agents are not counted
opencode, antigravity, crush none none: no readable or documented transcript, an explicit stub each

The window is 90 days: older transcript files are not opened and older events are not counted. count is that window's aggregate, summed over providers; the server replaces, never adds. A transcript line that is not the expected shape is skipped; it never fails the sync, and neither does a server that is unreachable, too old for the route, or refuses the token (a debug line). usage_scan: false in .cartographer.yaml turns the scan off entirely: nothing is read or sent, and the server degrades to "no data". Every scan reads the whole window afresh: there is no incremental state to go stale.

Client settings report (D818)

Next to the usage report, and under the same conditions (not a dry run, server reachable), sync posts this machine's client settings to POST /api/client-settings (reportClientSettings in cmd/cartographer/usagesync.go, built by clientconfig.Report): keys and origins, the .cartographer.yaml, the connected agent clients with their scope, workspace bindings (whatever the artifact scope) and the entry kind (bridge or http), and the resolved placeholders. The server keeps it in memory for the sending principal and shows it back to that principal only (docs/control-plane.md §Client settings reports). A failure never fails the sync.

Writing a placeholder literally, and how deep the repo scan goes

Documenting the generic form of a placeholder would otherwise trigger a warning at every sync, which trains people to ignore warnings. Two ways out (D162):

  • the escape, authoritative: {{\repo:<key>}} emits the literal {{repo:<key>}} with the backslash removed and resolves nothing. Chosen over doubling the braces, unreadable in a document that is about the syntax, and over an HTML-comment wrapper, since skills are also read as plain markdown;
  • metasyntax, a convenience needing no edit to existing KBs: a key of the form <name>, or ..., is left verbatim and silent. A real key cannot look like that — repoindex keys are git remote names or path keys, and < > are legal in neither.

The repo scan descends 4 directory levels from each root (a constant since D906; it was the search_depth setting, D162). A workspace organised deeper than that is served by a closer root in search_roots or a paths entry, which is also what the not-found message tells. search_roots defaults to ~/Documents, ~/src, ~/code and ~/projects (a default root that does not exist on the machine is skipped without a warning), and is consulted only for a key with no paths entry. Keying stays on the normalised git remote, never on a directory name: that is what makes a repo: key identical for every operator.

A cache hit is not trusted blindly (D181): before a cached path is returned, repoindex checks that it still holds a live clone (a directory with a .git entry) — a moved or removed clone behaves as a cache miss and triggers a rescan, instead of resolving to a location that no longer exists (or, worse, to a leftover clone left behind at the old path). Editing search_roots in .cartographer.yaml also invalidates the cache — a changed root set is a rescan even for a key that is still present under the old roots, since root order decides which clone wins when more than one exists.

The generated instructions block

The client steering file carries one managed block, shared by every mounted KB, delimited by <!-- cartographer:instructions:begin … --> / <!-- cartographer:instructions:end -->. The block is brief by default (D1182): it is paid on every model call of every session, and it grew linearly with the bound KBs (nine KBs measured at about 40k tokens, one at 3k). Inside it each KB gets its own named, delimited region <!-- cartographer:kb:<name>:begin --> … <!-- cartographer:kb:<name>:end --> (D182) holding only the generated routing sentence (KB name, server, maps) and one line Skills: a, b, … with the names of the KB's valid skills, sorted and capped at 40 (+<n> more (kb_guide) after the 40th). Then, once per block, after the last region: the shared operational section (the "Operational instructions" bullets, written once, not per KB, plus the rule that procedures, runbooks and incidents are read whole with concept_read and a bullet saying kb_guide returns the full guide, each KB's own directives and the skills with descriptions on demand), the D75 WP4 local-paths table and the subagent sentence below. The KB's own instructions.md, its scope sentence and the skill descriptions are not in the block: they come from kb_guide (control-plane.md). The routing sentence that names every KB of the connection is the same text in the guide and in the block; its routed kb clause is kept short (D1246).

A KB opts its curated text into the block with <!-- cartographer: block: full --> on the leading lines of its instructions.md (D1182): that KB's region then also carries the scope sentence and the curated markdown exactly as before (cut at 64 KiB, D1110), wrapped but never rewritten, reflowed or re-levelled, and never nested under a generated heading (D182). Use it when the KB's directives must be in every session (an operator's standing rules); everything else stays brief. There is no global "full" mode and no client setting: the opt-in travels with the KB to every client. Directives are recognised only in the leading run of lines of the file, one per line in any order, blank lines tolerated between them, and stripped from the text; the run ends at the first line that is not one, so a directive documented in prose or placed after prose is just text (D162). preamble: none keeps its meaning ("no generated operational text") and may be combined with block: full.

The shared section is client-owned text. A release that edits it changes no artifact ContentHash, so applyInstructionsGroup folds it into the hash the lockfile records for the client-written tail of the block (the same field as the local-paths section, D262): the first sync after such a release rewrites the block once, the second writes nothing. It is not emitted when every KB's region carries the <!-- cartographer: preamble: none --> comment (the only thing of an opted-out brief KB that crosses), nor when a region already contains an Operational instructions: line. Mixed versions degrade, never break: a new client against a server that still sends the old full regions writes no second copy; an old client against a new server writes the brief regions without bullets, which still route and list skills, and the next client upgrade completes it. No version negotiation is added.

Directive collisions are scoped to what is in the block (D183, below): a brief KB carries none of its directives in the artifact content, so two brief KBs declaring different values for one key no longer fail the sync (each is read through kb_guide, one KB at a time); a block: full KB still collides.

The size is reported. cartographer status and sync print one line per provider whose managed block is over 16 KiB (about 4k tokens, tokens = bytes/4, approximate), naming the three largest regions (configurator.md). Informational: no exit code, no drift.

The block and the server's guide share one generator (D1097): internal/provisioning builds the routing sentence, the shared bullets and the scope sentence once. The guide (initialize, kb_guide) is the full rendering; the block is the brief one, whose shared section adds the lines that need a Cartographer client (no git in the clone, kb reauthor, the update notice) and which the guide leaves out. The brief region of a KB is pinned by golden tests; D1182 changed its ContentHash once, so every instructions artifact is re-synced a single time on upgrade.

The operational bullets forbid git in the KB's clone (D264): the last one tells the agent never to run git commands there and to report replication problems from sync_status to the operator. An agent's own git init, checkout -b or push is how a KB ends up forked across two branches of its remote; the server owns the clone and refuses writes on a branch other than the remote's default one.

The first operational bullet names the reading tools (D780): search or atlas_overview to orient, concept_read to read, and graph_context when the answer spans several linked pages, in one call instead of reading page by page and following links. Agents pick tools from what this line names.

The scope sentence attributes and bounds each KB's directives (D182): emitted immediately before the curated body, only when curated content exists, it says that what follows is that KB's own and governs work in its perimeter, and that the more specific source wins on a conflict with another KB's directives or with a repository's own instruction file, so a directive written for one KB's perimeter is never an unqualified, session-wide rule. DetectCollisions (D171) cannot catch this: for kind instructions, Name is the KB name, unique by construction, so two KBs can never collide on it — the strict merge is structurally blind to this class of conflict. A KB can still opt a specific fact into structured comparison by giving it a key (D183, below).

A KB may declare a session-global directive with a key to make a conflict with another KB detectable, not just attributable (D183): a line of the shape <!-- cartographer:directive:<key>:<value> -->, matched as a full line (after trimming) anywhere in the curated body — not only the first line — asserts a session-wide fact the KB stands behind, such as a working timezone or environment name. <key> and <value> must each be non-empty and contain no :, the same strict-not-clever shape as every other Cartographer marker; a malformed or partial line (an extra :, an empty key or value) is left as ordinary prose, and a KB documenting the syntax itself never triggers it, the same D162 metasyntax trap preambleNoneRe and the cartographer:kb: markers already account for — including the marker shown alone on its own line inside a fenced code block, the natural way to document a syntax: extractDirectives tracks fence state (```/~~~) and never matches inside one. Unlike preamble: none, the recognised line is not stripped — it stays in the rendered block exactly as authored, since it is the KB's own content, not a control signal aimed at the generator. DetectDirectiveCollisions scans every kind: instructions artifact in the same provider-scoped set MergeArtifactsStrict already compares (D170/D171) for declared (key, value) pairs: two KBs agreeing on a key's value are not reported, two KBs declaring different values for the same key fail the sync with a *CollisionError naming the key, every declared value, and which KB declared each — the same "error, not warning" stance D171 takes for a structural kind+name collision. Two KBs bound to two different providers cannot collide on a directive, for the same reason they cannot collide on kind+name.

Section order follows the provider's KB binding, with an alphabetical fallback (D182 WP2): a provider with an explicit binding (clientconfig.ClientBinding.KBs, D170) gets its sections in that declared order — the operator's own choice, not an accident of directory naming; a provider with no binding (the default, every known KB) keeps the alphabetical order as its documented, deterministic default. A KB present in the manifest but absent from the binding list sorts alphabetically after the declared ones, so an ordering change never drops a section. Position was already known to matter (D154, the English-preamble-first case below): with several KBs, whichever one sorted first alphabetically occupied that position by accident of naming rather than by anyone's choice. A pure reorder of a binding — same KB set, different sequence — still rewrites the block even though no artifact's ContentHash changed: applyInstructionsGroup compares the previous run's recorded section order (carried in the lockfile's instructions entries) against the newly computed one and treats a mismatch as its own rewrite trigger.

The subagent sentence describes what THIS client received (D154). It is emitted per provider at Apply time, not baked into the artifact, and names only agents whose kind has a destination for that provider: a client with no native subagent directory (crush) gets no sentence rather than being told to delegate to subagents they never received. The block's content therefore depends on the KB's agent set even though its artifact hash does not, so the rewrite trigger fires on an agent artifact too — otherwise the sentence would go stale the moment an agent was added.

A KB may opt out of the generated bullets by putting <!-- cartographer: preamble: none --> in the leading directive lines of its instructions.md (D1182: the run, not only the first line). The generated wrapper is hardcoded English, so a KB written in another language produced a steering file that switched language twice, with the English first — the position that sets the model's expected output language. This is deliberately not a localisation mechanism: the KB owns the prose instead. The one-line routing sentence always stays, being generated state rather than prose, so the residual is one English line. The directive is recognised in the leading run only, so a KB can document it in its own text without triggering it.

A kind the provider cannot receive is warned about on every run, computed from the manifest rather than from the diff: the per-artifact unsupported: line appears only on the run where that artifact enters the diff, after which the condition is invisible while the KB keeps declaring artifacts that are silently not installed.

A KB copy of a bundled skill under its pre-D958 name is warned about once per KB, also on every run: kb-create and the other old names reach the client next to the bundled cartographer-kb-* skills, so the agent sees one skill twice. The warning points to kb_repair check=legacy_skill_name, which moves the copy to the new name.

Security

  • Cryptographic signature gate. A configured KB signer creates a canonical Ed25519 envelope over domain, format version, source KB, kind, name, version and content hash. The remote client recomputes the content hash and verifies against out-of-band signing_keys pins before writing any provider file or lockfile. signed:true is verification output only; malformed, invalid, source-mismatched, unknown-key or tampered content fails the whole sync, and so does an artifact with no signature under a pinned KB (a KB with a configured signer signs every artifact it ships, instructions and MCP descriptors included, so a missing signature means it was stripped). Bundled artifacts use the separate built_in:true origin, which means "embedded in this binary" and is verified locally: the client sets it only for a bundle artifact whose kind, name and content hash match the bundle compiled into it, never from the server's source or built_in fields, and a bundle artifact that does not match fails the sync if it carries a signature; a bundled skill this client also embeds but with another hash (version skew) is replaced by the client's own embedded copy, with a notice; any other unmatched bundle artifact (unknown name, other kind) is skipped with a warning and never installed, while the rest of the sync proceeds.
  • Unsigned KB artifacts are installed, unless the KB is pinned (D906). The client has no trust setting and no --auto-trust (the flag is still accepted and ignored with a warning, because the session-start hook of an older version passes it). Everything a KB ships is installed after the pull's own checks — path traversal below, the signature against a pinned signing_keys entry above, which fails the whole sync when it does not verify — and signed stays verification output only: an artifact with no signature is installed and reported as trusted, not verified — but only for a KB with no signing_keys pin: a pinned KB accepts only signed artifacts. The exceptions are what runs on the machine, which waits for a local approval: an MCP descriptor, a hook, and an artifact shipping an executable file or declaring hooks or MCP servers in its frontmatter (below). Rotate keys by pinning the new public key first, switching the server signer second, then removing the old pin.
  • Path traversal. Artifact names and paths come from the server via JSON: provisioning.Apply rejects anything that is not filepath.IsLocal (no absolute paths, no ../) before writing. Applies to every kind.
  • Symlinked destinations are refused (D148). os.WriteFile on a symlinked path opens the target with O_WRONLY|O_TRUNC — it does not replace the link — so a symlinked client directory diverts the write outside the client, and when the target is another git repository it lands there with a provenance footer declaring a false origin. Every materialization walks the destination's components under the base dir with os.Lstat and refuses the first one that is not a plain file or a plain directory — a symlink, a device, a socket, a named pipe, or anything Go can only report as ModeIrregular, which is one of the two ways a Windows directory junction may appear (D216: the guard refuses both spellings, because which one Go uses there is unverified). A regular file where a directory is expected stays accepted, since MkdirAll already reports that accurately. The base dir itself is exempt, since it may legitimately be a link (a symlinked $HOME, a provider root from BaseDirEnv). The guard lives in internal/safewrite (a leaf package, so internal/configurator and internal/blocktext use it too): the MCP config files connect, sync and disconnect write, and the marker-delimited blocks in config.toml, are refused the same way, per provider (configurator.md §Symlinked config files and directories are refused). Removals are guarded like writes (the prune of a managed file, of an MCP entry, of a hook registration or of the instructions block): a refused one is reported per artifact as refused, the file stays in the lockfile and the next sync retries it. The same widened refusal applies to internal/sops' path walk, which has the same shape and the same exposure. The refusal is per artifact — a skill, an agent, an MCP entry, the instructions block, a hook with its registration, a prune alike: it is reported in the sync summary as refused: <kind>/<name> and in AppliedResult.Refused, the rest of the pass proceeds, and the next sync retries and reports again — the condition can only be cleared by the operator. An artifact not yet installed is left out of the lockfile; one already in it keeps its previous entries unchanged, because the files on disk are still ours and a prune or a disconnect must keep owning them. Every destination of a skill or hook (and, for a hook, the registration target of its scope: Antigravity's .agents/hooks.json or Crush's .crush.json in a workspace) is checked before the first file is copied, so a refusal of one of those touches nothing. A refusal that only shows later — stripping a stale registration or registering, after the files were copied — leaves the new files written and the lock on the old entry; the next sync's on-disk hash check heals it. A refused instructions block keeps the previous instructions entries and the paths-section hash, since the old block is still on disk. cartographer doctor reports a symlinked destination directly, since a sync only mentions it on a run where that artifact is in the diff. Symlinked client-config directories are ordinary (a dotfile manager, a monorepo checkout, a shared team directory, an earlier bootstrap that linked skills out of a source repo), which is why this is a refusal rather than an assumption.
  • Sync never carries secrets, only references (skills-services-secrets.md).

Pruning

  • Apply/PruneManaged remove only the paths in managed[] that are no longer in the manifest — never files not created by Cartographer. Healing (D139) obeys the same boundary: it reads and rewrites managed paths only.
  • Empty directories (pruneEmptyDirs): after every removal, it walks up the parent directories deleting the ones left empty, always stopping at known roots (.claude, .codex, .kiro, .opencode, .config, .config/opencode, .config/crush, .gemini, .copilot, in a workspace also .agents and .crush, or BaseDir; a test checks the list against both destination matrices), which are never removed (disconnecting a workspace can leave an empty .agents/ or .crush/ behind, as it can .claude/). os.Remove (never RemoveAll) is the natural guard against non-empty directories.
  • MCP configs reduced to empty (configurator.Remove): once the entry is removed, an empty mcpServers/mcp map is removed too; the config file of every provider whose registry entry sets DeletableWhenEmpty (all but Claude Code) is deleted when it is reduced to an empty shell. Exception: .claude.json (shared by Claude Code) is deleted only if Cartographer created it (.cartographer-created.json ledger) and it is left {}; a user's own file is kept. For .codex/config.toml, only the marker-delimited block is removed (the file is deleted if it ends up empty).
  • Per-file ownership within one artifact (D178): when a multi-file artifact is rewritten, the files it owned in the previous lock and no longer declares are removed with it, through the same prune (so they are reported, and empty directories are cleaned). The removal set comes from the previous lock, never from a directory listing, and only covers paths under the artifact's own destination directory: a file the user added inside a managed directory survives, and a registration written elsewhere (a hook's generated plugin) is never mistaken for a dropped file. A file dropped from a hook that stays in the manifest leaves alone: the hook's registration is stripped only when the hook itself is pruned (its name is no longer in the manifest, or a disconnect), otherwise the hook would stay installed and listed in the lock while no longer firing. The converse holds too: an update whose new spec the registrar skips (no hook.json, an invalid one, an event the client never fires) strips the previous registration, so the hook does not keep firing its old entry; the skip still warns. Without this, a file dropped upstream stayed on disk and left the lock — invisible to pruning, to ComputeDiff and to doctor, while an agent kept reading it. Files stranded by versions before D178 are absent from every lock: doctor reports them (check managed-files) and never deletes them.
  • Relocation (D360): an artifact still in the manifest whose lockfile paths are not under the provider's current destination (a release moved it) is rewritten at the new destination on the next sync, and its old files leave through the same prune, reported as moved <kind>/<name> and pruned <path>. Applies to every kind with a directory or file of its own except hook (its entries include generated companions outside the directory), and to mcp: an entry recorded in another config file is registered in the current one and removed from the old one (the mcp prune addresses the recorded path), which moves workspace Claude Code entries written to <workspace>/.claude.json before D363 into .mcp.json (#654). Not to instructions (a group block). The first OpenCode case is D360 itself (.opencode/skills, .opencode/agent → .config/opencode/{skills,agents}).
  • A file whose content is unchanged is never rewritten, with or without approvals pending: the instructions block compares the bytes it would write with the file's and, when they are equal, leaves the mtime alone and reports no "wrote" line (writeInstructionsBlockChanged, test TestApply_PendingApprovalSecondRunWritesNothing, #1326). A pending agent or hook is never in the lock, so it shows as "added" on every run and fires the block's rewrite trigger each time; the comparison is what keeps that from being a write.
  • Round-trip connect→disconnect leaves no residue, except for the provisioning roots themselves (deliberate boundaries): test TestRoundTrip_ConnectDisconnect_NessunResiduo. Details → D63.

On-disk verification and healing (D139)

Revision comparison answers "is the lockfile current"; it says nothing about the files. Every sync therefore also verifies the managed artifacts against the filesystem and restores what diverged: a skill edited by hand, an agent file deleted, a managed key or block removed from a shared file. The server is the source of truth — a restore is a rewrite, never a merge, and no backup copy is kept. That is exactly what the provenance stamp promises the reader.

Verification scope per kind, decided by what is verifiable:

Kind Check
skill, hook the artifact's own directory is re-hashed with the same helper that produced materialized_hash — an extra file left inside counts as modified, and a lost executable bit is part of the hash where the filesystem has one (see §Executable flag)
agent the single materialized file's bytes
mcp, instructions presence only of the managed key or marker block: the file is shared with the user, so its other content is never compared and never rewritten
hook on claude also: every settings.json command registered for it can run — not a backslash path on Windows, not an absolute path to a file that is not there. One that cannot is unrunnable, with the command and the reason in the finding's detail, and the next sync re-registers it (D267)
hook on codex also: no registration of it left in .codex/config.toml — one there is unregistered, so the next sync rewrites the hook and migrates the registration to hooks.json even when its content did not change (D236)

Findings are missing, modified, unregistered, unrunnable, or unknown. Existence and content are verified separately (D146), because they need different evidence: whether a path is still on disk needs no hash, whether its bytes changed does. So the destination is stat'd first, and unknown — the lockfile records no materialized_hash, having been written before D138 — applies only to an artifact that is on disk and whose content therefore cannot be compared. It is reported, never healed: treating it as drift would rewrite every artifact on every client at once on the first upgrade. A pre-D138 entry whose files are gone is missing like any other, and is healed. A read error is a finding too, never a fatal: one unreadable artifact must not abort the verification of the others.

cartographer sync --no-heal reports divergence and skips the restore, for someone deliberately iterating on a local copy. cartographer status counts on-disk divergence as drift, so a locally modified artifact exits 1. Healing obeys the pruning guarantee unchanged: only paths in managed[] are ever read, written or removed, and --dry-run writes nothing.

Provenance stamp (D138)

A materialized skill (its SKILL.md only) and agent carry a marker-delimited block appended to the file — <!-- cartographer:provenance:begin … --> / <!-- cartographer:provenance:end -->, the same convention as the instructions block, invisible in rendered Markdown. It states the source KB, the artifact's path inside that KB, the artifact's content hash, and one instruction: local edits are replaced on the next cartographer sync, and the supported way to change the file is artifact_write on that KB at that path. A bundled artifact says so instead, with no artifact_write line — changing it means changing the Cartographer release. Other kinds are never stamped: a hook's script and hook.json would change program semantics, mcp descriptors are JSON, and instructions already announces itself.

The block carries no timestamp and no manifest revision: both would change on every sync (the revision on any other artifact's change), rewriting every file and defeating hash comparison. It is rebuilt from the source content on every materialization, so re-stamping is a fixed point and an older version's block is replaced, never nested. Stamping is client-side only, like placeholder expansion: the KB's own copy is never modified.

Because the stamp changes the bytes on disk, the lockfile records two hashes per managed file: content_hash — the manifest artifact's hash, the one ComputeDiff compares — and materialized_hash, the hash of what was actually written (after expansion, stamping and any per-provider translation). An empty materialized_hash means "unknown" (a lockfile written before D138). Keeping them separate is what stops an artifact containing a placeholder from comparing unequal against the manifest on every sync: content_hash is never the expanded hash.

A changed resolution is a rewrite trigger too (#967). Expansion happens only when a file is written, so a clone that moved (or a cartographer paths set) would leave the old path in every artifact whose source did not change. Each client's lockfile records what every cited key resolved to (resolved_placeholders); on the next sync, an artifact citing a key whose resolution differs from the recorded one (or that stopped or started resolving) is rewritten, in each client separately. An unchanged resolution rewrites nothing, and a lockfile with no recorded placeholder state never reports drift. cartographer status runs the same check (ComputeDiffResolved), so it lists such an artifact under drift until sync rewrites it.

Order of operations, and what a failure leaves behind (D172)

runSync writes nothing before the manifest is fetched and verified:

  1. enumerateKBs (/health);
  2. compute the MCP entries per provider — no writes;
  3. deriveWorkspaceBindings (D1019), allProjections and manifestsForProjectionsPartial → the manifest of every projection, signature verification and cross-KB collision detection (D171);
  4. removeMCPEntries + applyMCPEntries;
  5. ensureBootstrapForProviders — never before the manifest passed its checks;
  6. commitDerived (the derived workspace bindings) and clientconfig.Save of known_kbs;
  7. materializeForProviders, which checkpoints the lockfile after every provider.

An unverifiable signature or a refused merge therefore leaves the machine exactly as it was, and the error says so. A failed sync_pull of one KB is narrower (D257): step 3 drops from the run every provider with a projection bound to that KB, before step 4, so those providers keep their previous state while the rest are applied, and the run ends with an error listing the failed KBs and the skipped providers. When no provider is left, or no KB answered, nothing is written, as before. Failures that no one KB can be blamed for stay fatal: /health, target resolution, the server's unnamed endpoint, two KBs serving one artifact with different signatures. An unreachable server (/health itself failing) skips entry reconciliation entirely, as before.

The guarantee, stated honestly. A failure between steps leaves a consistent state, and a provider that completed is always recorded in the lockfile (D172), so a failure on provider N never leaves providers 1..N−1 with files on disk and no lock entry that prune and doctor could see. It does not make a single Apply atomic: a provider whose Apply fails midway can still have partial files on disk. The cost is one atomic lockfile rename per provider instead of one, a deliberate trade of I/O for safety.

The client lock (D172)

The paths that read-modify-write the lockfile or .cartographer.yaml and can run while another process does — among them sync, disconnect, doctor --repair-hashes, the TUI's sync actions and the realignment (callers of LockClientState) — first take an advisory OS file lock on .cartographer-client.lock beside the lockfile. The race is between processes, not goroutines: the session-start bootstrap hook runs cartographer sync per agent session, so several are routinely in flight, and the loser of that race silently dropped another provider's entry. A blocked acquisition waits up to 30s and then fails naming the file — a sync that quietly loses an entry is worse than one that asks to be rerun. A dry run takes no lock, since it writes nothing.

The TUI's S (sync all) runs its providers sequentially under one lock rather than fanning out through tea.Batch: with the lock in place, concurrent goroutines would only queue behind each other while making the progress reporting incoherent. A partial failure reports which providers completed.

Idempotence

sync_apply/provisioning.Apply applied twice on the same revision are no-ops; dry_run shows the diff without writing (sync_apply(dry_run=true), --dry-run on the client). The client plan covers everything the run would write: per-artifact files, the MCP entries it would add and the ones it would remove, and the known_kbs rewrite when the set changes; with --client it says in its header that the plan is restricted to those providers (D172). The provider JSON config merge remains the non-destructive deep-merge of configurator.mergeJSON.

Kind × provider matrix

The matrix below is data in the code: destinationMatrix in internal/provisioning/provisioning.go, resolved by destDir(kind, name, provider) (D137). Every cell either names a destination or is explicitly unsupported — a cell missing from the table fails a completeness test instead of degrading silently. unsupported is not needs_approval (no approval would unblock it): clients filter such artifacts out upstream with FilterForProvider, and they count as neither drift nor pending (D50). provisioning.Destinations(kind, name) reads the same matrix for the Atlas UI's Artifacts panel, which shows an artifact's clients (D238); provisioning.Kinds() lists next to it every kind a KB can hold — the matrix kinds, with their KB path and the doc that explains them, plus the two that never leave the KB (templates, secrets) — so the panel shows a kind the KB has none of, with the clients that would receive it (D864). A test keeps the list honest: a file written at each KBPath is found by the scanner, and a matrix kind without a descriptor fails the build.

Kind claude opencode codex kiro antigravity crush copilot
skill .claude/skills/<name>/ .config/opencode/skills/<name>/ .codex/skills/<name>/ .kiro/skills/<name>/ .gemini/config/skills/<name>/ .config/crush/skills/<name>/ .copilot/skills/<name>/
agent .claude/agents/<name>.md (verbatim) .config/opencode/agents/<name>.md (translated) .codex/agents/<name>.toml (translated) .kiro/agents/<name>.json (translated) .gemini/config/agents/<name>.md (translated) unsupported — no documented user-level subagent directory .copilot/agents/<name>.agent.md (translated)
hook .claude/hooks/<name>/ + registration in settings.json .opencode/hooks/<name>/ + generated JS plugin .codex/hooks/<name>/ + registration in hooks.json .kiro/hooks/cartographer/<name>/ + entry in .kiro/hooks/cartographer.json (fires in kiro-cli chat --v3 --tui only, D300) .gemini/config/hooks/<name>/ + registration in hooks.json .config/crush/hooks/<name>/ + entry cartographer-<name> in hooks.PreToolUse of crush.json (PreToolUse only, D363) .copilot/cartographer-hooks/<name>/ + dedicated file .copilot/hooks/cartographer-<name>.json (SessionStart, PreToolUse, PostToolUse; no matcher, D676)
instructions managed block in .claude/CLAUDE.md block in .config/opencode/AGENTS.md block in .codex/AGENTS.md file .kiro/steering/cartographer.md block in .gemini/GEMINI.md block in .config/crush/CRUSH.md block in .copilot/copilot-instructions.md
mcp key mcpServers.<name> in .claude.json key mcp.<name> in .config/opencode/opencode.json (D1172) block [mcp_servers.<name>] in config.toml key mcpServers.<name> in .kiro/settings/mcp.json key mcpServers.<name> in .gemini/config/mcp_config.json key mcp.<name> in .config/crush/crush.json key mcpServers.<name> in .copilot/mcp-config.json

Paths are relative to the client base dir, the user's home. A global lock entry carries no base_dir and means the lockfile's own directory; a workspace projection records its repository.

SOURCE.env beside a materialized skill

Every KB skill gets a generated SOURCE.env next to its SKILL.md (D316), so a script the skill ships finds its KB without parsing local config:

# Generated by Cartographer at sync: the KB this skill came from.
CARTOGRAPHER_KB_NAME=kb-a
CARTOGRAPHER_KB_ROOT=/srv/kbs/kb-a

CARTOGRAPHER_KB_ROOT is written only when the KB's root is on the materializing machine (a local deployment, or the server's own sync_apply); a client fed over HTTP has no copy of the KB, so it gets the name only. Values are not shell-quoted. The file is part of the skill's managed files — hashed into its materialized hash, recorded in the lockfile, removed by disconnect — and is regenerated on every materialization; it is never written into the KB. A skill materialized before D316 gets it the next time it changes. A KB skill that ships its own SOURCE.env is a collision, that skill is not materialized, with a warning naming it, and the rest of the sync completes.

Workspace scope (D193)

By default a provider has one catalogue, under the user's home, shared by every session of that provider on the machine. A provider that works in two perimeters therefore sees both perimeters' skills everywhere, and chooses between them from name and description alone — the provenance footer is in the body, read only after activation. That is how a WORK skill got activated in a HomeLab session.

cartographer workspace bind <provider> <path> --kb <name>… switches that provider to workspace scope: each bound directory receives its own KBs, in that directory's own project-local configuration, and nothing KB-sourced is written globally.

provider scope (default) workspace scope
Where KB artifacts land the client base dir ($HOME) each bound workspace
Who sees them every session of that provider only sessions in that workspace
The binding one per provider (D169/D170) one per provider and workspace
Lockfile key provider provider + workspace, in its own namespace

Cartographer's own bundled skills stay global. cartographer-ops, cartographer-kb-create and their siblings belong to no perimeter, and a session outside every bound workspace still needs them. They are the only thing the global catalogue of a workspace-scoped provider holds.

An unbound workspace is fail-closed. It receives the transversal bundle and no KB artifact at all. "No KBs" and "several KBs" are distinct explicit states; neither is ever "every KB", and a bound path that is gone or whose git remote has changed is an error, never a fall-back — falling back is the exposure the scope exists to close.

Which KBs a session reaches: the stdio bridge (D737). Skills are scoped by the projection; the tools are scoped by the global MCP entry. A provider with at least one workspace binding gets, as its global entry, cartographer mcp --provider <p> (a local stdio command) instead of the HTTP URL. The bridge resolves the folder it was started in (os.Getwd, never $PWD: OpenCode starts its children with $PWD set to $HOME) with the binding table, deepest binding wins, so a binding on $HOME and one on $HOME/projects/<area> coexist, and proxies the whole session to the routed endpoint with ?kbs=<set>. The server already enforces the set and refuses any other kb. Nothing is written into the workspace for this: no .mcp.json, no project entry, whichever client. Rules: a folder under no binding, $HOME included, gets the provider's default set read-only (D1018): the bridge sends access=read and the server lists no write tool and refuses a call to one, naming cartographer workspace bind and a KB's paths.yaml as the ways to write; the folder also receives no KB artifact. That is the default: the client setting unbound_access (default: <mode> for every provider, <provider>: <mode> overrides; read or write, anything else is read; #1356) turns an unbound folder read-write instead, still without artifacts (they stay tied to a binding, D193). The bridge then sends no access=read, so the session is an ordinary routed one, and its initialize instructions say it is unbound (the bridge appends the sentence to what the server answers, in a JSON or an SSE answer). The setting is inert for a provider on the HTTP entry (no binding, already read-write), a bound folder never changes, and it is not a security boundary (D737). A bridge whose server does not confirm the mode (Cartographer-Access: read, older servers) serves no tools. Until that confirmation the bridge forwards one message at a time, and a response that opens an event stream (subscriptions/listen) releases the next one as soon as its headers confirm the mode, so the stream never holds back tools/list. A binding that wins is never read-only. An empty binding (--no-kb), or one whose KBs are all unmounted, gets a session with no Cartographer tools, never every KB (the bridge answers the handshake itself and sends nothing to the server); a binding whose directory is gone or whose git remote changed serves no KB and says why. A client that declares MCP roots while the working directory is under no binding is scoped by the union of the bindings its roots resolve to; the scope is fixed once, at the start of the session. A provider with no binding keeps its HTTP entry, and unbinding the last workspace returns to it on the next sync. The bridge reads the token the way sync does: a literal token in .cartographer.yaml, else the environment variable that token: ${NAME} names (CARTOGRAPHER_TOKENS by default), else the private token file; the entry never holds it, and for Codex, which filters the environment of an MCP server, names the variable in env_vars. The command is the one found on PATH, never the path of the running binary (a versioned Cellar directory). sync removes the project entries that an earlier version wrote into repositories (D717). cartographer workspace resolve [<path>] prints which binding wins for a folder.

A repository a KB declares is bound to it (D1019). A KB's paths.yaml already declares the repositories it works on (repos:), and each resolves to a local clone like any {{repo:<key>}}. For every provider in workspace scope, sync binds that clone to the KB, with no workspace bind run there: the KB's skills and agents are projected into the repository (a folder binding above it cannot do that, for most clients look for project skills only up to the repository root, harnesses.md) and the bridge started in it serves the KB. A provider in provider scope is unchanged.

  • Opt out per entry: bind: false on a repos: entry keeps the placeholder without binding the repository (a repository the KB only cites). bind: true and no bind are the same.
  • Several KBs declaring one clone bind it to all of them, as workspace bind --kb kb-a,kb-b would; a same-named artifact in two of them is refused for that projection (D171).
  • By path, the remote is the guard, and it is required. paths.yaml is unsigned KB content, so a declared remote: matched by the clone's origin is what makes binding safe: an entry without a remote:, and a clone with no origin or another one, is not bound and is listed as unresolved. Otherwise editing one KB's paths.yaml could project skills, agents and hooks into an unrelated checkout.
  • By path. The binding is the clone the placeholder resolves to (the paths: entry, else the repo index by the declared remote, else the declared default), never every clone of the remote. Another clone, or a git worktree, of the same repository is not bound: workspace bind covers it. A clone whose origin is not the declared remote is not bound either, and the recorded remote guards it afterwards as for an explicit binding.
  • An explicit binding wins on its path, --no-kb included: that is the local opt-out. Otherwise the deepest binding wins across explicit and derived ones together, so a declared repository inside a bound folder gets its own set.
  • Derived state, not a preference. The list is recomputed by every sync from the registries it pulls and stored apart from .cartographer.yaml, next to the repo index cache (workspace-derived.json). A repository that stops being declared, gets bind: false or loses its clone is dropped on the next sync and its projection pruned like an unbound workspace. A KB that could not be pulled leaves its providers' list as it was. A declared key with no clone is listed by status and workspace list as declared, no local clone.
  • A derived binding never edits a file git tracks. An explicit bind writes the instructions block inside a tracked CLAUDE.md/AGENTS.md (above); a derived one would do it in every declared repository with no command run there, and a KB name in a tracked file of a public repository is a leak. Where the client's instructions file is tracked it writes none, and status/doctor say so, naming the file and the explicit-bind alternative; Claude Code instead gets its block in CLAUDE.local.md, which it reads beside CLAUDE.md, owned whole and excluded in .git/info/exclude. Skills, agents and hooks are paths Cartographer owns and excludes, so they are projected as for an explicit bind. Claude Code registers a derived projection's hooks in .claude/settings.local.json, the settings layer it reads beside .claude/settings.json and that a repository does not version, excluded like CLAUDE.local.md (both in the first sync's exclude block, whichever other client is projected into the same repository); the confirmation line names that file; never in .claude/settings.json (D1198). When git tracks settings.local.json too, the hooks are not projected and status says so. A hook an earlier version registered in a settings.json git does not track is moved to settings.local.json; one in a tracked settings.json is not registered a second time: a sync that updates the hook warns, doctor reports the entries, and once they are removed the next sync registers the hook in settings.local.json. Wherever Cartographer still rewrites a Claude Code settings file, the key order, the indent, the line endings and the final newline are kept (string escapes are normalised). What a derived projection leaves unwritten is neither pending nor drift in status (#1188): it stays only in the note, and neither the diff nor the instructions n/m counter lists it. The same holds for the other shared files a projection writes whole, the MCP file (.mcp.json, .kiro/settings/mcp.json, ...) and a registration file: when git tracks it, a derived projection does not write it, status says not written: <file> is tracked by git under the workspace, and the provider keeps its global MCP entry (and a hook whose registration file is that tracked file is not projected either, which status also says); the lock entries of what is not written are kept as they are, never pruned. Any other collision that remains, a KB artifact with the name of a tracked path, skips that one projection with a warning naming the workspace, the provider and the paths, and the run goes on with the others. An explicit workspace bind keeps the refusal (D1181). Codex keeps its caveat: an untrusted project ignores .codex/, reported inactive; a derived binding never records trust (no command was run to carry the consent), and status names the line to add.

workspace list shows a derived binding with its source (derived: paths.yaml of kb-a) and workspace resolve says which one won.

Who a session's writes are authored as (D904). On each tools/call, and on nothing else, the bridge sends the Cartographer-Author header for the KB the server will pick: the kb argument, else the session's only KB; with several reachable and none named it sends none, and the server refuses the call anyway. The value is the KB's identities entry in .cartographer.yaml, else identities.default, else what git resolves in the folder the KB was bound from (the session's folder, or for a scope from roots the roots that bound it, which must agree), so an includeIf gitdir: setup applies to KB writes as to commits. A present entry always decides: an empty one sends no author, an invalid one sends none and the bridge logs it once. Git is asked only inside a work tree, never guesses user@host (user.useConfigOnly), and is asked once per folder for the session (a folder with no identity again after 30s). A call that writes to two KBs (target_kb) carries one author for both commits, so it is sent only when both KBs resolve to the same one. The identities keys are the client's KB names: a renamed KB's old name, which the server resolves through its state file, is not one. Without a header the server authors the write as the identity configured for the KB in the server file (git.identity, git.identities), else the token.

Who an HTTP entry's writes are authored as (D904). A provider without a workspace binding (or whose bridge cannot start) keeps an HTTP entry, written once into a file every folder shares, so connect and sync give it a static Cartographer-Author header. For each KB the entry reaches, the value is the KB's identities entry, else identities.default, else the global git identity (git config --global, never a folder's). It is written only when all those KBs agree: an entry that reaches KBs with different writers sends none, and an entry without ?kbs= also answers for every KB identities names and any KB mounted later. A global git config with an includeIf is not read for it, since a folder may get another identity there: set identities.default (empty to send none) or bind a workspace, whose bridge reads each folder. Either case prints a warning on every connect and sync. Which clients take the header: harness ledger.

A projection is not an authorization boundary. ?kbs= is a scope filter, not a security boundary: the token in the session's environment can reach every KB it allows. Hard isolation needs a per-workspace token (D44/D45).

A process running as the same user can read any file on the machine (D169). What this prevents is accidental exposure and activation, and that is all it claims.

Project-local destinations

Kind claude opencode codex kiro antigravity crush copilot
skill .claude/skills/<name>/ .opencode/skills/<name>/ .agents/skills/<name>/ .kiro/skills/<name>/ .agents/skills/<name>/ .crush/skills/<name>/ .agents/skills/<name>/
agent .claude/agents/<name>.md .opencode/agent/<name>.md .codex/agents/<name>.toml .kiro/agents/<name>.json .agents/agents/<name>.md unsupported unsupported (gated on folder trust)
hook .claude/hooks/<name>/ .opencode/hooks/<name>/ .codex/hooks/<name>/ unsupported .agents/hooks/<name>/ + entry in .agents/hooks.json .crush/hooks/<name>/ + entry in .crush.json (D363) unsupported (gated on folder trust)
instructions block in ./CLAUDE.md (D293: prepends @AGENTS.md when the project has one) block in ./AGENTS.md block in ./AGENTS.md .kiro/steering/cartographer.md block in ./AGENTS.md unsupported block in ./AGENTS.md
mcp .mcp.json opencode.json .codex/config.toml .kiro/settings/mcp.json .agents/mcp_config.json key mcp.<name> in .crush.json (D363) unsupported (not listed in an untrusted folder)

Paths are relative to the workspace. The matrix is data, like the global one, and is held to the same completeness rule: every kind × provider cell either names a destination or is explicitly unsupported. Since D364 every provider has at least one project-local cell, so every provider can be bound to a workspace; one with no cell at all would be refused by workspace bind rather than degraded to the global catalogue. Antigravity projects every kind into its workspace .agents/ layer and AGENTS.md (probed on CLI 1.3.2); a rule file is not used for instructions because it is read only with trigger: always_on.

Shared paths. .agents/skills/ is Codex's project skill directory too, and AGENTS.md is shared by Codex, OpenCode and Antigravity. Two providers bound to one workspace write such a path identically, so a prune never deletes a path that another provider's projection of the same workspace still records in the lockfile (ApplyOptions.CoOwnedPaths, also applied when an unbind prunes an orphan projection).

Codex is the one provider where correct files are not the whole story: it ignores a project's .codex/ layer unless the project is trusted. status and doctor report such a projection as inactive and name the fix, because reporting it as installed is the false-positive class D189 exists to eliminate.

cartographer workspace bind codex <path> --kb <name>… --trust (D1074) records the folder as trusted on request. The flag is the consent: no prompt, valid for codex only (any other provider is a usage error), never implied by --kb, sync or a timer. It appends one table to the user's ~/.codex/config.toml, [projects."<path>"] with trust_level = "trusted" and nothing else, outside any managed block (Codex rewrites that file and drops comments, markers included, D99), as text: every existing byte stays an exact prefix, a missing file is created 0600, an existing file keeps its mode, and the write is a temp file renamed over the original. Already trusted is a success that writes nothing. It writes nothing and prints the exact line to add, with a non-zero exit (the binding itself is saved first and stays), when the file or a directory above it is a symlink (dotfile managers link it), when the file already mentions the path (an explicit untrusted included: a refusal is never overridden) or declares projects as a dotted key, an inline table or a [projects] table (a second declaration is a duplicate key and stops Codex from starting), or when the path holds a quote or control character. A path with a backslash is written as a TOML literal string. Without the flag, bind prints the line when the folder is not trusted yet. unbind leaves the table alone, it is the user's record. Copilot is not covered.

Repository hygiene

A projection writes into a directory the user version-controls, so two rules hold:

  • A projection owns the artifacts it writes, not the directories it writes into: in a skills, agents or hooks directory that is <dir>/<name> for each artifact planned in this run and each path the lockfile records for the projection, so a repository's own skills in .agents/skills/ next to the projected ones are neither refused nor excluded (D1181).
  • A workspace projection's lines in sync and sync --dry-run are labelled [<provider> <workspace>], with the home directory shown as $HOME: a printable label that grep reads as text (#1200).
  • Cartographer excludes only its own untracked paths, and only in the repository's info/exclude (.git/info/exclude), inside a marker-delimited block. It never edits .gitignore: that file is the repository's, shared with everyone who clones it. A shared file the repository already tracks — a CLAUDE.md the team wrote — is the user's: Cartographer writes its block inside it and does not exclude it. The block is one per workspace and lists the paths of every provider bound to it, including one a sync --client run does not touch; unbinding a provider rewrites it with the others' paths, and only the last one leaving removes it (#656). The block lists every path the projection owns, the hook companions that sit outside the hook directories included: Codex's .codex/hooks.json (unless the repository tracks it, then it is the repository's) and the generated OpenCode plugins under .config/opencode/plugins/. The workspace root is never a line of it. cartographer doctor lists the untracked files under a provider's directories that neither the block nor an ignore file covers (#1199). Where .git is a file, as in a linked worktree or a submodule, the exclude file is resolved the way git does, without running git: the gitdir: line, then the commondir file if the git directory has one. A linked worktree shares info/exclude with the main checkout and every other worktree, so its block is its own, keyed by the worktree's name in the markers (# >>> cartographer (managed: <name>) >>>): ensuring or removing it never touches the main checkout's plain block or another worktree's. For the same reason the file is shared, a worktree's paths are also excluded in the main checkout and in its sibling worktrees. The blocks of worktrees that git worktree remove has deleted are dropped by the next ensure or removal from any of them. A submodule's git directory is its own, so it keeps the plain block. A .git file is the repository's content, not the user's, so its target is trusted only when it is this workspace's own git directory: a worktree's must hold HEAD, link back to this workspace's .git and sit at <commondir>/worktrees/<name>; a submodule's must hold HEAD, lie (once symlinks are resolved) under the modules/ directory of an enclosing repository and name this workspace as its core.worktree. Anything else (another directory, ../ traversal, a sibling submodule's directory, a UNC path on Windows, a symlinked pointer file) is a warning and writes nothing, and the warning never repeats what the file says. A layout nothing ties to the workspace, such as git init --separate-git-dir, is such a case: it gets the warning and no exclude block, so the projected paths show up in git status until they are ignored by hand.
  • A path Cartographer would own entirely (an artifact it writes, or a file such as .mcp.json) that git already tracks is a refusal, before anything is written: a KB skill with the name of a tracked one is refused, naming it; a tracked skill of another name is not. An artifact name that is not a single local segment, or holds a line break or a gitignore special character, fails the projection before anything is written (a derived one is skipped with a warning); this is stricter than the name check at write time, because the name also goes into the exclude file. Overwriting a versioned file destroys work under version control, and there is no safe silent answer.

git status is clean after a sync. That is asserted end-to-end by the 18_workspace_projection E2E scenario, along with the rest of this section.

status judges the provider the way sync applies it: the global projection against the bundle only, each bound workspace against its own manifest and lock, so a clean sync is in-sync (exit 0) (D1011).

Unbinding

cartographer workspace unbind <provider> <path> removes the declaration; the next sync prunes what was projected there and removes the exclusion block. Unbinding the last workspace returns the provider to the global scope — the only other state there is. Neither ever touches another workspace's files: the lockfile keys workspace projections in a separate namespace, so a prune cannot reach past its own projection.

Agents and hooks

  • KB layout (optional/backward-compatible): agents/<name>.md (Claude subagent: frontmatter + body) and hooks/<name>/ (script + hook.json with event/matcher/command). BuildManifest scans these (KB only, the bundle remains skill-only).
  • ContentHash: agent → sha256 of the file; hook/skill → an aggregate hash of the directory (ContentHashDirOS); always computed on the source, never on the translated form — so the manifest↔lock comparison doesn't depend on the provider.
  • Agent translation (translateAgentForProvider, a pure function): the source is always a Claude subagent. Claude = passthrough; OpenCode = minimal frontmatter description + mode: subagent + verbatim body; Codex = TOML name/description/developer_instructions; Antigravity = Markdown frontmatter with name, description, mainAgent: false, subagent: true; Copilot = Markdown frontmatter with name and description in <name>.agent.md (D676); Kiro = a JSON config with name/description/prompt (D195 — the format the shipped client discovers, which is not the Markdown its documentation describes). The body stays verbatim; fields that can't be mapped reliably (tools, model) are dropped, not guessed. Dropping tools widens the agent, since in Claude Code an omitted tools means every tool, so it is never silent (D283, D291): every sync on a non-Claude client prints one warning per KB agent that declares tools, naming the agent, the client and the list, and an agent whose frontmatter says strict_tools: true is not installed there at all (reported as unsupported, left out of the client's counts, and removed if an earlier sync had installed it). strict_tools without tools does nothing. Per-client native fields (D291): an agent may carry a providers: block keyed by client (opencode, codex, kiro, antigravity, copilot; Claude takes its own fields at the top level). Each entry is copied verbatim into that client's native agent file, after the fields Cartographer writes (description/mode for OpenCode, name/description/developer_instructions for Codex, name/description/prompt for Kiro, the Antigravity header, name/description for Copilot), in the file's own syntax (YAML, TOML or JSON). Nothing is inferred or checked against what the client accepts; a client name Cartographer does not translate for, a non-map entry or a key Cartographer writes itself (the ones listed above) is rejected by artifact_write and fails the translation. A client with a providers: entry counts as having its own restriction: the widened-agent warning is not printed for it and strict_tools does not skip the agent there. Which native keys actually restrict an agent (OpenCode permission — tools is no longer in its agents documentation, so prefer permission — Codex sandbox_mode, Kiro tools/allowedTools) is believed, not verified against the real clients: the mechanism copies whatever the author writes. The warning says so (write that client's own restriction under providers.<client>). When an entry's tools or allowedTools cites an MCP server as @<server> or @<server>/<tool> (Kiro's syntax; @builtin excepted) and the client's global MCP config on this machine has no entry of that name, every sync prints an advisory warning naming the agent, the client and the server — the agent would see only built-in tools — and still installs the entry verbatim (D320). artifact_write rejects an agent whose frontmatter name differs from its file name, because Claude registers the agent under the name and the other clients under the file. Details → D55/D58.
  • Hook registration: besides materializing the files, Apply registers the hook in the provider's native mechanism (internal/provisioning/hooksettings.go), idempotently and prunably:
  • claude: merges the entry into hooks.<Event>[] of settings.json (ownership = the .claude/hooks/<name>/ marker in the command: the materialized path, or — for commands that don't reference the hook's dir, e.g. a one-liner jq ... — an inert shell comment # cartographer-hook: ... appended at the end); the file is treated as generic JSON, unknown keys survive (D57);
  • codex: an entry in .codex/hooks.json, whose shape is Claude Code's settings.json hooks, patched in place by the same .codex/hooks/<name>/ ownership marker; other integrations' entries (Herdr's, the user's) are preserved, and a hooks.json left holding nothing is removed on prune (D230). A registration an older client wrote into .codex/config.toml — the # cartographer:hook:<name>:begin/end block, and any marker-less copy Codex's own rewrite left outside it (D99) — is migrated out on the next Apply and reported in AppliedResult.Warnings: Codex keys its hook trust by file, so it asks to trust the moved hook once. An unchanged hook is migrated too, through on-disk verification (D236), and any table Codex placed inside the old block that is not one of the hook's [[hooks.<Event>]] registrations (a [notice], trust bookkeeping) is moved out of it before the block is deleted. doctor counts a registration still in config.toml as a stray, since it fires alongside the hooks.json one;
  • opencode: a deterministic JS plugin cartographer-<name>.js in ~/.config/opencode/plugins/, generated only if the event is mappable (openCodeHookEvents); an unmappable event → files are still materialized + a warning in AppliedResult.Warnings (D59). The plugin shape follows the installed OpenCode major (D359): 1.x named export, 2.x export default { id, setup }; unknown version → the newest known shape plus a warning in AppliedResult.Warnings (D719);
  • crush (D363): an owned entry name: "cartographer-<name>" in hooks.PreToolUse of ~/.config/crush/crush.json (project: .crush.json), upserted and removed by that name with every other entry and key kept. Only PreToolUse fires on Crush (probed on v0.98.0), so any other event stays materialized with a warning. The matcher is the KB's prefixed with (?i), because Crush's tool names are lowercase. No session event: no bootstrap hook (the sync trigger is the timer) and no write-findings hook;
  • copilot (D676): a dedicated file ~/.copilot/hooks/cartographer-<name>.json ({"version":1,"hooks":{"<event>":[{"type":"command","bash":…,"powershell":…}]}}) that Cartographer owns whole (the hook's own files sit in ~/.copilot/cartographer-hooks/<name>/, because Copilot parses every *.json below ~/.copilot/hooks/, subdirectories included) and returns as a managed file, so a prune deletes it like OpenCode's generated plugin; ~/.copilot/settings.json is never patched. SessionStart, PreToolUse and PostToolUse map to sessionStart, preToolUse, postToolUse (the three probed on 1.0.94); another event stays materialized with a warning. Copilot has no matcher, so a hook that declares one is not registered (it would fire for every tool) and warns. The session hook makes the bootstrap hook possible, and the write-findings hook is installed without a matcher and filters on the <server>-<tool> tool name in its command (--channel copilot: {"additionalContext": …} on stdout);
  • antigravity: an owned top-level cartographer-<name> definition in ~/.gemini/config/hooks.json (in a workspace projection, in that workspace's .agents/hooks.json; the file is chosen from the hook's own path, and an emptied project file is deleted). PreToolUse/PostToolUse preserve matcher groups; SessionStart, PreInvocation, PostInvocation, and Stop use direct handlers. Other events remain materialized and produce a warning because Antigravity has no native equivalent;
  • kiro: one entry per hook, keyed by the hook's name, in ~/.kiro/hooks/cartographer.json, a standalone v1 hook file Cartographer owns whole ({"version":"v1","hooks":[{name, description, trigger, action: {type: "command", command}, timeout, enabled}]}). It is regenerated from the set of registered hooks, sorted by name, and removed with its last entry, since the engine rejects a file with an empty hooks list. It is not a managed file of each hook: the hooks' own files are, and pruning one hook strips only its entry. The files sit in ~/.kiro/hooks/cartographer/<name>/, one level below the directory the engine scans, because its loader reads only the *.json files directly inside ~/.kiro/hooks/. A user's own hook files there are never opened for writing. SessionStart, UserPromptSubmit and Stop are registered; Kiro's tool events were never probed and are not claimed. The command keeps the host's spelling, because Kiro runs it through $SHELL -c on unix and PowerShell on Windows. These hooks fire only in kiro-cli chat --v3 --tui, and status says so (hook 1/1 (fires in kiro-cli chat --v3 --tui only)) (D300);
  • a missing/malformed hook.json skips registration without failing Apply, and is reported: one AppliedResult.Warnings entry per provider naming the hook and the reason (invalid JSON, missing event/command, no hook.json), because the files are still materialized and counted while nothing will fire (D284). The events are one declared vocabulary (hookEventReach, internal/provisioning/hookspec.go: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, PreCompact, plus Antigravity's PreInvocation/PostInvocation), each with the providers that fire it. On claude, codex and kiro an event the client does not fire (PreInvocation/PostInvocation) is not registered and warns; an event outside the vocabulary is registered as declared with a warning that a misspelling never fires. artifact_write and lint (hook_invalid) apply the same check earlier, on the KB side (control-plane.md, data-plane.md); a command whose first token is a relative path — one containing either separator, e.g. ./notify.sh or scripts\run.sh — is resolved to the materialized absolute path, and quoted with double quotes if that path contains whitespace (C:\Users\Nome Cognome\...), idempotently: registration runs on every connect/sync and never re-quotes what it already quoted. Bare names (e.g. jq) are left verbatim, resolved via PATH. An absolute command whose own path contains a space must arrive already quoted from the KB: unquoted, C:\Program Files\x.exe --flag is indistinguishable from the command C:\Program with arguments, so it is left as declared rather than guessed at. On Windows, a command bound for a POSIX shell — every Claude Code hook, which Claude Code runs through Git Bash, and the sh -c of a generated OpenCode plugin — has the backslashes of its executable turned into forward slashes (C:/Users/user/.claude/hooks/…): the shell would otherwise delete them and the hook would never run. Arguments are left verbatim. An entry written before this, with the backslash form, is still recognised as the hook's own and replaced by the next sync, not duplicated (D267).
  • Executable flag. A file's executable bit comes from the KB and is preserved on materialization (including skill scripts); hooks retain an unconditional executable floor for every file other than hook.json, which is always non-executable. The effective mode is part of the versioned artifact hash, so chmod alone changes the revision and realigns existing installs. On Windows the filesystem has no execute permission (internal/execbit): the flag still travels in the manifest and is still stored in the KB, but a file read back from disk there never reports it, and a mode difference there is not drift — a KB script that round-trips through a Windows client's disk loses its bit (D219).

This is why the flag is lockfile state, not an observation, where the filesystem has none. materialized_hash is recorded from what the writer intended, so on-disk verification must reproduce that same intent: it takes the flag from the lockfile's executable_paths instead of from the mode, falling back — for a lockfile written before that field, or one with nothing executable — to the kind's own rule, which is exact for a hook and means "not executable" for everything else. Without it a skill shipping an executable helper reported modified on every check, permanently, with no repair able to clear it. On unix nothing changes: there the filesystem is the authority, because an external chmod is real drift and reporting it is the point. The artifact hash itself is untouched by all of this — normalising the mode inside the digest would make every client's expanded hash differ from the server's content_hash and re-materialize every installation at once (D216). - Per-kind counts: provisioning.KindCounts → a skill 4/5 · agent 2/2 · hook 1/1 line in cartographer status and the TUI. - Pending hook hint: under a hook that is new or updated, status says where the sync registers it for that provider, read from provisioning.HookPluginRelPath / HookRegistrationFile (a generated file, or the provider's registration file under the scope's base directory); it never asks the user to register by hand, because Apply does it for every provider with a hook destination (#1012).

Instructions (imprinting)

For every mounted KB, BuildManifest generates a kind: instructions artifact (name = KB name) whose content does not live on disk: it is produced by generateKBInstructions (pure and deterministic) and placed in Artifact.Files. It contains: a header (the KB is served via MCP) with the names of data/'s top-level maps inline ("Maps: a/, b/.", #1316) (no page counts — it's stable imprinting, not state, D65), operational instructions, built from pieces shared with the server's initialize guide (D1097) (start with search/atlas_overview, read with concept_read, write with concept_write, close with log_append; every write is a commit; never run git in the KB's clone, report sync_status instead — D264), the names only of the KB's agents (descriptions already come from the client's agent registry — the agent is installed natively, D65), and the verbatim content of the optional curated file <kbRoot>/instructions.md (at the root, never under data/). ContentHash = sha256 of the generated content: it changes only if the set of maps/agents or the curated file changes — not on every added page. Details → D56/D61 and D65.

Materialization: a managed block delimited by markers (<!-- cartographer:instructions:begin/end -->) inside the provider's global instructions file (see the matrix above) — a user file, never overwritten or deleted:

  • rewrite = replacement between the markers; missing markers → block appended at the end; missing file → created with only the block;
  • managed as a group: the block is the ordered concatenation of the snippets of all currently signed instructions — a removed KB disappears on the next rewrite; zero artifacts → block removed;
  • each snippet is individually wrapped and attributed (D182): wrapKBSection delimits it with <!-- cartographer:kb:<name>:begin --> / :end --> — a marker family distinct from the outer cartographer:instructions: one, so the malformed-block check (which counts occurrences of the outer pair) still counts exactly one of each no matter how many KBs contribute. generateKBInstructions additionally emits a one-line scope sentence right before the curated body, when curated content exists, naming the KB and stating that the more specific source wins on a conflict — the sentence is generated content, so it is part of ContentHash like the rest of the block;
  • order follows ApplyOptions.KBOrder (D182 WP2), set by the client from the provider's explicit binding (clientconfig.ClientBinding.KBs) when there is one, alphabetical by KB name otherwise; a KB outside the declared order sorts alphabetically after the declared ones. A pure reorder — same KB set, only the sequence changed — is invisible to ComputeDiff (no ContentHash differs), so applyInstructionsGroup separately compares the previous run's recorded order (the sequence of instructions entries in the incoming Lock) against the newly computed one and treats a mismatch as its own trigger;
  • the signature gate applies as usual; lockfile: one ManagedFile per artifact (not per physical file);
  • pruning: removes only the block, never the file — except when the file is left empty (this covers kiro's dedicated file).

Path portability placeholders (D75)

Shared content (concepts and provisioning artifacts) must never contain machine-specific (client-local) absolute paths: two placeholders, resolved client-side only, at materialization time:

  • {{repo:<name>}} (short form) / {{repo:<host>/<owner>/<name>}} (full, canonical form) — resolved automatically: the key is the normalized git remote (internal/repoindex, handles ssh scp-like, ssh://, https://, .git suffix), identical on every machine of the team. repoindex.Scan walks search_roots (client config, default ~/Documents, ~/src, ~/code and ~/projects) up to depth 4, reads each repo's .git/config (no git exec) and caches the result in ~/.config/cartographer/repos.json, refreshed on-miss. A cache hit is validated before use (D181): the candidate path must still be a directory containing a .git entry, or it is treated as a miss and a rescan follows; a change of search_roots also invalidates the cache, even for a key whose old path is still perfectly live.
  • {{path:<name>}} — manual paths: mapping in .cartographer.yaml, a fallback for directories that aren't git repos (and an override for {{repo:<key>}} too: repoindex.Resolve checks paths: before cache/scan). Written by the operator with cartographer paths set, or answered at the placeholder step of an interactive connect (docs/configurator.md); never by sync.

The KB's placeholder registry (D263)

A KB declares the keys it may cite in paths.yaml at its root (docs/data-plane.md): per key a description, an optional default that must start with ~/ or $HOME/, and — for a repo — an optional remote. sync_pull serves it parsed as path_registry (malformed entries left out and named in issues); it is never materialized. The client keeps it per KB, gives each projection the registries of its bound KBs (ApplyOptions.PathRegistries), and Apply merges them in the provider's KB order: when two KBs declare one key with a different default or remote, the first wins and one warning per sync names both.

Resolution order, per key:

  1. .cartographer.yaml paths: — always wins;
  2. for repo: — the repo index, looked up by the declared remote when there is one (so another clone sharing the short name is not a match, nor an ambiguity), else by the key;
  3. the declared default, used only if it exists on this machine (for repo:, only if it is a live clone);
  4. otherwise unresolved, with a reason naming the missing default.

A declared key nothing cites is offered too: it joins the "Local paths" table when it resolves and is dropped silently when it does not — it is never reported unresolved. Trust: the registry is unsigned KB content, like a concept body. It can only propose a path under the reader's home, only as a fallback, only when that path exists, and it executes nothing; the client re-checks the home anchor itself, so a default that is absolute or climbs out with .. is ignored whatever server sent it.

The server-side machine_path lint flags a literal client-local path left in a concept body instead of one of these placeholders. Not every absolute path in a body is client-local: a path inside a URL is not one (D124), a container or cluster path (/etc/…, /var/…) is never a candidate, and the conventional tool paths every machine agrees on (~/.ssh/config, ~/.kube/config, D313) are documentation. No map declares more exceptions (D907): a remote node's home directory is written $HOME/…, a clone or a local folder as a placeholder.

No server-side expansion: it would break content_hash/if_match (hash on the raw content, content served differently per client) and the server doesn't know clients' filesystems. internal/mcpserver never sets ApplyOptions.ExpandPlaceholders — only cmd/cartographer (connect/sync/TUI) does, passing cfg.SearchRoots/cfg.Paths.

  • Expansion points (provisioning.Apply, before writing to disk): agent content (single-file), skill/hook content (every file in the folder), instructions content (per artifact, before composing the group block).
  • Hash of the lock: ManagedFile.ContentHash stays the manifest artifact's hash, expanded or not (the written bytes are materialized_hash, above), because ComputeDiff compares it on the next run. For an instructions block this holds too (#1175): a block citing a placeholder, resolved or not, converges, the second sync writes nothing and status is ok. A changed resolution of what the block cites reaches it through the paths-section hash (the "Local paths" table), not through content_hash, and status makes the same comparison, so it reports the block pending until a sync rewrites it. A hand edit inside the managed block is not tracked: it persists until the block is rewritten (a manifest or resolution change), with or without placeholders.
  • Which keys are resolved (D262): sync_pull lists the keys a KB cites (placeholders, see docs/control-plane.md) — the union of the placeholders in the bodies of the concepts the caller may see, read from the graph cache, and those in the KB's own artifacts. The list is outside revision and outside every signature, like Manifest.Issues. The client keeps it per KB and gives each projection the keys of the KBs bound to it (ApplyOptions.Placeholders); Apply adds the keys found in the manifest's own authorized artifacts, so an older server that lists nothing still gets artifact-level coverage. Every key is resolved before any artifact is expanded, through one repoindex.Resolver: the search roots are walked at most once per Apply, however many keys miss. The parser (okf.Placeholders) is shared by server and client: escaped ({{\repo:…}}) and metasyntax keys (<name>, ..., …) are never listed.
  • Unresolved placeholder: the text left as-is in the file — sync never blocks on a missing resolution. Reported as one aggregated warning per sync (not one per occurrence or per provider) (D282): a single summary line (N placeholder(s) not resolved on this machine … cartographer paths list), followed by detail only for the keys that were not already unresolved in the lock the projection started from (AppliedResult.PriorUnresolved), with a reason shared by several keys printed once and the two fixes, cartographer paths set and cartographer paths ignore. A key the operator marked absent (ignored_paths: in .cartographer.yaml) is left out of the warning and of status; it is still resolved like any other and still recorded in the lock.
  • Placeholder state in the lock (D262): each Lock records resolved_placeholders (key → local path), unresolved_placeholders (key → reason), placeholder_sources (key → citing KBs) and placeholder_decls (key → its paths.yaml declaration, D263), overwritten by every client-side Apply. cartographer paths and status read them; absent (an older lockfile) means "nothing recorded", never a failure.
  • Placeholder paragraph and "Local paths" table: when ExpandPlaceholders is active, applyInstructionsGroup appends to the instructions block (per provider) a fixed paragraph — always, even when nothing resolved — explaining that concepts and artifacts may cite {{repo:<key>}}/{{path:<name>}}, that an unlisted one is resolved with cartographer resolve <kind>:<key>, and that on failure the agent asks the user instead of guessing, recorded with cartographer paths set — and that a writer cites only the keys the KB's paths.yaml declares, declaring a new one in the same change (D263). Below it, a placeholder → local path table with every key resolved in that Apply (the whole KB's list, concept-only keys included). The section is appended after expansion, so nothing in it is ever resolved. Its hash is recorded in the lock (paths_section_hash): a mismatch rewrites the block even when no artifact changed — a concept that starts citing a key, or a new paths: entry, is not in the manifest diff.
  • Repo ambiguity: a short name matching several distinct remotes is an explicit error (it asks for the full form host/owner/name); multiple clones of the same remote resolve to the first match in search_roots order, with a warning.

MCP servers (D69, D116)

A KB can distribute third-party MCP servers (endpoints that connected agents must be able to call) alongside skills/agents/hooks/instructions. Two transports are supported: http (D69) and stdio (D116, see below); any other type fails the build.

  • KB layout: mcp/<name>.json, one file per server (single-file like agents, not a directory like skills). Provider-neutral schema {"type":"http","url","headers"?,"env"?} or {"type":"stdio","command","args"?,"env"?}. BuildManifest parses+validates here (parseMCPServerSpec): malformed JSON, an unknown field, an unsupported type, a missing url/command, a field belonging to the other transport, or a headers/env value that doesn't reference a ${VAR} (looks like a literal secret) fail the build, not the apply. No mcp/ folder → zero artifacts (backward-compatible).
  • No secrets in the file: headers and env values only support ${VAR} references, resolved by the client against its own environment at materialization time — the same pattern as token_env (D64). On an "http" server env is validated but not emitted: no provider exposes a verified native channel for generic env vars there — only the Authorization: Bearer ${VAR} header can be reliably represented. On a "stdio" server every provider does have a native env channel, so references are emitted (D116).
  • Emission (internal/configurator.EmitServer): a refactor that extracts from Emit/ServerConfig (Cartographer's own entry) a provider-neutral core EmitServer(name, spec ServerSpec, provider), reused both by connect (for the cartographer entry) and by Apply for KB servers. Translation of the ${VAR} reference: claude, kiro, codex, antigravity and copilot leave it verbatim (native syntax); OpenCode translates it to {env:VAR}; Crush rewrites it to $VAR and refuses a value carrying a command substitution ($(), which Crush would execute when it loads its config. Codex only exposes bearer_token_env_var for auth: an Authorization: Bearer ${VAR} header is translated there, any other header is not representable and is dropped with a warning (EmitResult.Warnings) instead of failing. Kiro has never had a header field for MCP servers (a pre-existing limitation, not introduced by D69): a KB server with headers generates a warning in AppliedResult.Warnings, not an error.
  • Apply: unlike skills/agents/hooks, an MCP server does not materialize its own file/folder — it merges into the provider's native config (internal/provisioning/mcpsettings.go, registerMCPServer/removeMCPServer), with per-name ownership so multiple MCP servers (and Cartographer's own entry) coexist in the same file: every provider but codex does a JSON merge on the mcpServers/mcp key of its config file; codex uses a marker-delimited block # cartographer:mcp:<name>:begin/end (distinct from the unnamed block cartographer connect writes for itself) via internal/blocktext. destDir("mcp", ...) resolves to the same shared file for all servers of that provider (like instructions), but — unlike instructions (a GROUP block) — each server is an independent ManagedFile.
  • Prune/disconnect: PruneManaged strips only that name's entry/block (never the whole file; a name recorded in two config files, like a relocated entry, is removed from each, since the removal is remembered per name and file), with the same empty-shell cleanup as configurator.Remove (D63): the config file of every provider whose registry entry sets DeletableWhenEmpty is deleted if the removal leaves only $schema/nothing; .claude.json is deleted only when Cartographer created it and it is left {}; .codex/config.toml only loses the marker-delimited block.
  • Client consent (D906): the server distributes every valid descriptor of a KB (there is no per-KB mcp_allowlist), but an MCP server receives the agent's data, so the client decides. Every descriptor, signed or not, requires cartographer approve mcp <name> --kb <kb>, binding consent to source, name and full content hash: a signature proves the origin, and any rw token on the KB can write a descriptor (D906). Nothing else authorizes an MCP descriptor. Any hash change returns it to needs_approval; revoke then sync prunes managed provider config.
  • Client consent for what runs (D1141): a KB hook, any KB artifact shipping a file with the execute bit (a skill's script, say), and a skill or agent whose frontmatter (also inside a per-client providers: block) declares hooks or MCP servers, follow the same contract as a descriptor, signed or not: the server signs whatever a token wrote, so a signature proves the origin and is never consent. cartographer approve <kind> <name> --kb <kb> shows the files, the hook.json and the content of every executable file, and binds consent to source, kind, name and full content hash (the execute bit is part of the hash). Until then the artifact is needs_approval, is neither written nor registered, and what an earlier sync installed stays on disk exactly as it is (not refreshed, not removed, still in the lockfile), reported pending on every sync and status. A content change returns it to needs_approval. An artifact the provider cannot receive at all is unsupported, never pending (D50). sync_apply holds no approval, so these land in its needs_approval. Bundled artifacts are unaffected. The approval is stored with the MCP ones in mcp_approvals, under <kind>/<name>. cartographer approve --pending approves every pending artifact after one confirmation, and the first sync of a client approves by itself what the lockfile shows installed with the hash the KB serves now (D1166, configurator).
  • Remote sync: no per-kind filtering in sync_pull/tools_sync.go nor in the HTTP client — an mcp artifact travels as a single ArtifactFile (the mcp/<name>.json file), same schema as an agent.

Stdio descriptors (D116). A descriptor may use {"type":"stdio","command","args"?,"env"?}, which rejects url and headers. The command is a bare executable name resolved through PATH or a clean absolute path, in either platform's spelling — POSIX, drive-absolute (C:\srv\srv.exe, C:/srv/srv.exe) or UNC (\\host\share\srv.exe) — because a descriptor lives in the KB and is validated by every client, whichever wrote it: the verdict is the same on every host. Never a shell expression: Cartographer passes command and arguments separately and never starts a shell. Shell metacharacters and NUL are refused; parentheses are the one exception and only for an absolute command, so C:\Program Files (x86)\srv\srv.exe is expressible — an absolute command is handed to exec.Command with a separate argv and never reaches a shell, while a bare name has no reason to carry one (D216). Arguments keep their order and env values are ${VAR} references only, so no secret value ever reaches the server or a provider file. The artifact content hash binds the exact command, every argument and every reference: changing any field invalidates a D115 approval. Before any provider config or lockfile is written, PreflightStdioMCP resolves the command locally for every target provider (PATH lookup for bare names, executable regular-file check for absolute paths — on Windows the extension is what makes a file executable, so PATHEXT decides and the permission check does not apply) and fails the whole sync naming the provider it protected; the resolved path is not persisted and the executable is never launched. Claude Code, Codex and Kiro receive native command/args/env; OpenCode receives type: "local", an ordered command array and environment with ${VAR} translated to {env:VAR}. A field a provider cannot represent fails instead of being silently dropped, so no provider ever runs a command different from the approved one.

Implementation choices

  • Dedup by kind+name: a skill present both in the bundle and in a KB is materialized only once, with the KB winning. The manifest holds exactly one artifact per kind+name.
  • Per-provider projection (D170): each provider receives only the KBs bound to it in .cartographer.yaml (clients.<provider>.kbs, see configurator.md §cartographer client). Three rules make it work:
  • selection happens before the merge, on the per-KB sync_pull responses. Filtering the merged manifest is wrong: MergeArtifacts has already discarded candidates, so if kb-A overrides a bundled skill and a provider is bound only to kb-B, the merge keeps kb-A's copy and a source filter then deletes it — the provider loses the skill instead of receiving the bundled one. The server performs the same KB-over-bundle merge inside each single-KB pull, so a candidate the client never received cannot be reconstructed;
  • the revision is recomputed per provider, after selection and after FilterForProvider. Two providers holding different KB sets under one revision string would make ComputeDiff.InSync lie, and changing a binding would produce no drift at all. FilterForProvider also drops kinds the provider has no destination for (e.g. agent for crush), so the recomputed revision differs from the pre-projection one whenever a provider supports less than the full kind set. The CLI's synced to revision … (and would sync to revision … under --dry-run) reports the revision each provider actually recorded — AppliedResult.NewLock.AppliedRevision, taken from materializeForProviders' return value, never the manifest sync fetched before projection — so it always matches what status reports for that provider (D184);
  • unbinding removes the artifacts for free: they leave the provider's manifest, so ComputeDiff marks them Removed and PruneManaged deletes them. Only files listed in the lock are touched, so user-owned files survive. A server that does not identify its KBs by name (no kbs in /health, or a first sync before any name is known) cannot express a binding: every client receives everything, exactly as before D170, with a warning saying so. ManagedFile.Source records each file's origin KB; empty means "unknown" (a lockfile written before D170) and is never treated as wrong.
  • Cross-KB collisions are refused, not resolved (D171). Two KBs claiming the same kind+name is an error: MergeArtifactsStrict — what the client uses — fails with a report naming the kind, the name and the claiming KBs, before anything is materialized. MergeArtifacts stays tolerant and keeps the alphabetical source tie-break; the server builds a manifest from one KB plus the bundle, where the case cannot arise. cartographer client bind warns when a new binding creates one, and doctor's kb-collisions check reports it per provider.
  • Lockfile: <base-dir>/.cartographer-sync.lock.json (v2 multi-provider).
  • Pruning is per tracked file, not per whole directory.

Out of scope

  • Bidirectional sync (client → server): the flow is unidirectional, the server is the source of truth.
  • Dynamic secret rotation/manager (→ skills-services-secrets.md).
  • Conflicts on skill content (that's git, KB-side → concurrency.md).