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):
- the client calls
sync_pullonce per KB in the union of every connected provider's binding (§Per-provider projection) and keeps the responses unmerged. The calls run concurrently, bounded bymaxConcurrentPulls(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; - for each provider it selects its bound KBs' responses (
SelectForSources), merges them withprovisioning.MergeArtifactsStrict— which refuses akind+nameclaimed by two of that provider's KBs — and verifies signatures; - it reconstructs each artifact hash from received paths, bytes and executable modes, then verifies any detached signature against the local KB pin;
Applymaterializes/prunes and writes the v2 lockfile, one entry per provider;- 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 —repoindexkeys 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_keyspins before writing any provider file or lockfile.signed:trueis 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 separatebuilt_in:trueorigin, which means "embedded in this binary" and is verified locally: the client sets it only for abundleartifact whose kind, name and content hash match the bundle compiled into it, never from the server'ssourceorbuilt_infields, and abundleartifact 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 unmatchedbundleartifact (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
trustsetting 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 pinnedsigning_keysentry above, which fails the whole sync when it does not verify — andsignedstays verification output only: an artifact with no signature is installed and reported astrusted, notverified— but only for a KB with nosigning_keyspin: 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.Applyrejects anything that is notfilepath.IsLocal(no absolute paths, no../) before writing. Applies to every kind. - Symlinked destinations are refused (D148).
os.WriteFileon a symlinked path opens the target withO_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 withos.Lstatand 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 asModeIrregular, 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, sinceMkdirAllalready reports that accurately. The base dir itself is exempt, since it may legitimately be a link (a symlinked$HOME, a provider root fromBaseDirEnv). The guard lives ininternal/safewrite(a leaf package, sointernal/configuratorandinternal/blocktextuse it too): the MCP config filesconnect,syncanddisconnectwrite, and the marker-delimited blocks inconfig.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 asrefused, the file stays in the lockfile and the next sync retries it. The same widened refusal applies tointernal/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 asrefused: <kind>/<name>and inAppliedResult.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.jsonor Crush's.crush.jsonin 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 doctorreports 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/PruneManagedremove only the paths inmanaged[]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.agentsand.crush, orBaseDir; 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(neverRemoveAll) is the natural guard against non-empty directories. - MCP configs reduced to empty (
configurator.Remove): once the entry is removed, an emptymcpServers/mcpmap is removed too; the config file of every provider whose registry entry setsDeletableWhenEmpty(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.jsonledger) 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, toComputeDiffand todoctor, while an agent kept reading it. Files stranded by versions before D178 are absent from every lock:doctorreports them (checkmanaged-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 asmoved <kind>/<name>andpruned <path>. Applies to every kind with a directory or file of its own excepthook(its entries include generated companions outside the directory), and tomcp: 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.jsonbefore D363 into.mcp.json(#654). Not toinstructions(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, testTestApply_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→disconnectleaves no residue, except for the provisioning roots themselves (deliberate boundaries): testTestRoundTrip_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:
enumerateKBs(/health);- compute the MCP entries per provider — no writes;
deriveWorkspaceBindings(D1019),allProjectionsandmanifestsForProjectionsPartial→ the manifest of every projection, signature verification and cross-KB collision detection (D171);removeMCPEntries+applyMCPEntries;ensureBootstrapForProviders— never before the manifest passed its checks;commitDerived(the derived workspace bindings) andclientconfig.Saveofknown_kbs;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: falseon arepos:entry keeps the placeholder without binding the repository (a repository the KB only cites).bind: trueand nobindare the same. - Several KBs declaring one clone bind it to all of them, as
workspace bind --kb kb-a,kb-bwould; 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.yamlis unsigned KB content, so a declaredremote:matched by the clone's origin is what makes binding safe: an entry without aremote:, and a clone with no origin or another one, is not bound and is listed as unresolved. Otherwise editing one KB'spaths.yamlcould 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 agit worktree, of the same repository is not bound:workspace bindcovers 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-kbincluded: 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
syncfrom 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, getsbind: falseor loses its clone is dropped on the nextsyncand 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 bystatusandworkspace listasdeclared, 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, andstatus/doctorsay so, naming the file and the explicit-bind alternative; Claude Code instead gets its block inCLAUDE.local.md, which it reads besideCLAUDE.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.jsonand that a repository does not version, excluded likeCLAUDE.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 trackssettings.local.jsontoo, the hooks are not projected andstatussays so. A hook an earlier version registered in asettings.jsongit does not track is moved tosettings.local.json; one in a trackedsettings.jsonis not registered a second time: asyncthat updates the hook warns,doctorreports the entries, and once they are removed the nextsyncregisters the hook insettings.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 instatus(#1188): it stays only in the note, and neither the diff nor theinstructions n/mcounter 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,statussaysnot written: <file> is tracked by gitunder the workspace, and the provider keeps its global MCP entry (and a hook whose registration file is that tracked file is not projected either, whichstatusalso 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 explicitworkspace bindkeeps the refusal (D1181). Codex keeps its caveat: an untrusted project ignores.codex/, reportedinactive; a derived binding never records trust (no command was run to carry the consent), andstatusnames 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
syncandsync --dry-runare labelled[<provider> <workspace>], with the home directory shown as$HOME: a printable label thatgrepreads 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 — aCLAUDE.mdthe 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 async --clientrun 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 doctorlists the untracked files under a provider's directories that neither the block nor an ignore file covers (#1199). Where.gitis a file, as in a linked worktree or a submodule, the exclude file is resolved the way git does, without running git: thegitdir:line, then thecommondirfile if the git directory has one. A linked worktree sharesinfo/excludewith 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 thatgit worktree removehas 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.gitfile 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 holdHEAD, link back to this workspace's.gitand sit at<commondir>/worktrees/<name>; a submodule's must holdHEAD, lie (once symlinks are resolved) under themodules/directory of an enclosing repository and name this workspace as itscore.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 asgit init --separate-git-dir, is such a case: it gets the warning and no exclude block, so the projected paths show up ingit statusuntil 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) andhooks/<name>/(script +hook.jsonwithevent/matcher/command).BuildManifestscans 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 frontmatterdescription+mode: subagent+ verbatim body; Codex = TOMLname/description/developer_instructions; Antigravity = Markdown frontmatter withname,description,mainAgent: false,subagent: true; Copilot = Markdown frontmatter withnameanddescriptionin<name>.agent.md(D676); Kiro = a JSON config withname/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. Droppingtoolswidens the agent, since in Claude Code an omittedtoolsmeans every tool, so it is never silent (D283, D291): everysyncon a non-Claude client prints one warning per KB agent that declarestools, naming the agent, the client and the list, and an agent whose frontmatter saysstrict_tools: trueis not installed there at all (reported asunsupported, left out of the client's counts, and removed if an earlier sync had installed it).strict_toolswithouttoolsdoes nothing. Per-client native fields (D291): an agent may carry aproviders: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/modefor OpenCode,name/description/developer_instructionsfor Codex,name/description/promptfor Kiro, the Antigravity header,name/descriptionfor 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 byartifact_writeand fails the translation. A client with aproviders:entry counts as having its own restriction: the widened-agent warning is not printed for it andstrict_toolsdoes not skip the agent there. Which native keys actually restrict an agent (OpenCodepermission—toolsis no longer in its agents documentation, so preferpermission— Codexsandbox_mode, Kirotools/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'stoolsorallowedToolscites an MCP server as@<server>or@<server>/<tool>(Kiro's syntax;@builtinexcepted) and the client's global MCP config on this machine has no entry of that name, everysyncprints 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_writerejects an agent whose frontmatternamediffers 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,
Applyregisters the hook in the provider's native mechanism (internal/provisioning/hooksettings.go), idempotently and prunably: - claude: merges the entry into
hooks.<Event>[]ofsettings.json(ownership = the.claude/hooks/<name>/marker in thecommand: the materialized path, or — for commands that don't reference the hook's dir, e.g. a one-linerjq ...— 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'ssettings.jsonhooks, patched in place by the same.codex/hooks/<name>/ownership marker; other integrations' entries (Herdr's, the user's) are preserved, and ahooks.jsonleft holding nothing is removed on prune (D230). A registration an older client wrote into.codex/config.toml— the# cartographer:hook:<name>:begin/endblock, and any marker-less copy Codex's own rewrite left outside it (D99) — is migrated out on the nextApplyand reported inAppliedResult.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.doctorcounts a registration still inconfig.tomlas a stray, since it fires alongside thehooks.jsonone; - opencode: a deterministic JS plugin
cartographer-<name>.jsin~/.config/opencode/plugins/, generated only if the event is mappable (openCodeHookEvents); an unmappable event → files are still materialized + a warning inAppliedResult.Warnings(D59). The plugin shape follows the installed OpenCode major (D359): 1.x named export, 2.xexport default { id, setup }; unknown version → the newest known shape plus a warning inAppliedResult.Warnings(D719); - crush (D363): an owned entry
name: "cartographer-<name>"inhooks.PreToolUseof~/.config/crush/crush.json(project:.crush.json), upserted and removed by that name with every other entry and key kept. OnlyPreToolUsefires 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*.jsonbelow~/.copilot/hooks/, subdirectories included) and returns as a managed file, so a prune deletes it like OpenCode's generated plugin;~/.copilot/settings.jsonis never patched.SessionStart,PreToolUseandPostToolUsemap tosessionStart,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/PostToolUsepreserve matcher groups;SessionStart,PreInvocation,PostInvocation, andStopuse 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 emptyhookslist. 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*.jsonfiles directly inside~/.kiro/hooks/. A user's own hook files there are never opened for writing.SessionStart,UserPromptSubmitandStopare 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 -con unix and PowerShell on Windows. These hooks fire only inkiro-cli chat --v3 --tui, andstatussays so (hook 1/1 (fires in kiro-cli chat --v3 --tui only)) (D300); - a missing/malformed
hook.jsonskips registration without failingApply, and is reported: oneAppliedResult.Warningsentry per provider naming the hook and the reason (invalid JSON, missingevent/command, nohook.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'sPreInvocation/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_writeandlint(hook_invalid) apply the same check earlier, on the KB side (control-plane.md,data-plane.md); acommandwhose first token is a relative path — one containing either separator, e.g../notify.shorscripts\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 everyconnect/syncand 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 --flagis indistinguishable from the commandC:\Programwith 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 thesh -cof 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 nextsync, 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, sochmodalone 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):
wrapKBSectiondelimits it with<!-- cartographer:kb:<name>:begin -->/:end -->— a marker family distinct from the outercartographer: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.generateKBInstructionsadditionally 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 ofContentHashlike 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 toComputeDiff(noContentHashdiffers), soapplyInstructionsGroupseparately compares the previous run's recorded order (the sequence ofinstructionsentries in the incomingLock) against the newly computed one and treats a mismatch as its own trigger; - the signature gate applies as usual; lockfile: one
ManagedFileper 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://,.gitsuffix), identical on every machine of the team.repoindex.Scanwalkssearch_roots(client config, default~/Documents,~/src,~/codeand~/projects) up to depth 4, reads each repo's.git/config(nogitexec) 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.gitentry, or it is treated as a miss and a rescan follows; a change ofsearch_rootsalso invalidates the cache, even for a key whose old path is still perfectly live.{{path:<name>}}— manualpaths:mapping in.cartographer.yaml, a fallback for directories that aren't git repos (and an override for{{repo:<key>}}too:repoindex.Resolvecheckspaths:before cache/scan). Written by the operator withcartographer paths set, or answered at the placeholder step of an interactiveconnect(docs/configurator.md); never bysync.
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:
.cartographer.yamlpaths:— always wins;- for
repo:— the repo index, looked up by the declaredremotewhen there is one (so another clone sharing the short name is not a match, nor an ambiguity), else by the key; - the declared
default, used only if it exists on this machine (forrepo:, only if it is a live clone); - 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.ContentHashstays the manifest artifact's hash, expanded or not (the written bytes arematerialized_hash, above), becauseComputeDiffcompares 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 andstatusisok. A changed resolution of what the block cites reaches it through the paths-section hash (the "Local paths" table), not throughcontent_hash, andstatusmakes 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_pulllists the keys a KB cites (placeholders, seedocs/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 outsiderevisionand outside every signature, likeManifest.Issues. The client keeps it per KB and gives each projection the keys of the KBs bound to it (ApplyOptions.Placeholders);Applyadds 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 onerepoindex.Resolver: the search roots are walked at most once perApply, 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 setandcartographer paths ignore. A key the operator marked absent (ignored_paths:in.cartographer.yaml) is left out of the warning and ofstatus; it is still resolved like any other and still recorded in the lock. - Placeholder state in the lock (D262): each
Lockrecordsresolved_placeholders(key → local path),unresolved_placeholders(key → reason),placeholder_sources(key → citing KBs) andplaceholder_decls(key → itspaths.yamldeclaration, D263), overwritten by every client-sideApply.cartographer pathsandstatusread them; absent (an older lockfile) means "nothing recorded", never a failure. - Placeholder paragraph and "Local paths" table: when
ExpandPlaceholdersis active,applyInstructionsGroupappends 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 withcartographer resolve <kind>:<key>, and that on failure the agent asks the user instead of guessing, recorded withcartographer paths set— and that a writer cites only the keys the KB'spaths.yamldeclares, declaring a new one in the same change (D263). Below it, a placeholder → local path table with every key resolved in thatApply(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 newpaths: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 insearch_rootsorder, 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"?}.BuildManifestparses+validates here (parseMCPServerSpec): malformed JSON, an unknown field, an unsupportedtype, a missingurl/command, a field belonging to the other transport, or aheaders/envvalue that doesn't reference a${VAR}(looks like a literal secret) fail the build, not the apply. Nomcp/folder → zero artifacts (backward-compatible). - No secrets in the file:
headersandenvvalues only support${VAR}references, resolved by the client against its own environment at materialization time — the same pattern astoken_env(D64). On an "http" serverenvis validated but not emitted: no provider exposes a verified native channel for generic env vars there — only theAuthorization: Bearer ${VAR}header can be reliably represented. On a "stdio" server every provider does have a nativeenvchannel, so references are emitted (D116). - Emission (
internal/configurator.EmitServer): a refactor that extracts fromEmit/ServerConfig(Cartographer's own entry) a provider-neutral coreEmitServer(name, spec ServerSpec, provider), reused both byconnect(for thecartographerentry) and byApplyfor 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$VARand refuses a value carrying a command substitution ($(), which Crush would execute when it loads its config. Codex only exposesbearer_token_env_varfor auth: anAuthorization: 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 inAppliedResult.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 themcpServers/mcpkey of its config file; codex uses a marker-delimited block# cartographer:mcp:<name>:begin/end(distinct from the unnamed blockcartographer connectwrites for itself) viainternal/blocktext.destDir("mcp", ...)resolves to the same shared file for all servers of that provider (likeinstructions), but — unlikeinstructions(a GROUP block) — each server is an independentManagedFile. - Prune/disconnect:
PruneManagedstrips 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 asconfigurator.Remove(D63): the config file of every provider whose registry entry setsDeletableWhenEmptyis deleted if the removal leaves only$schema/nothing;.claude.jsonis deleted only when Cartographer created it and it is left{};.codex/config.tomlonly 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, requirescartographer approve mcp <name> --kb <kb>, binding consent to source, name and full content hash: a signature proves the origin, and anyrwtoken on the KB can write a descriptor (D906). Nothing else authorizes an MCP descriptor. Any hash change returns it toneeds_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) declareshooksor 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, thehook.jsonand 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 isneeds_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 everysyncandstatus. A content change returns it toneeds_approval. An artifact the provider cannot receive at all isunsupported, never pending (D50).sync_applyholds no approval, so these land in itsneeds_approval. Bundled artifacts are unaffected. The approval is stored with the MCP ones inmcp_approvals, under<kind>/<name>.cartographer approve --pendingapproves every pending artifact after one confirmation, and the firstsyncof 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.gonor in the HTTP client — anmcpartifact travels as a singleArtifactFile(themcp/<name>.jsonfile), 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 perkind+name. - Per-provider projection (D170): each provider receives only the KBs bound to it in
.cartographer.yaml(clients.<provider>.kbs, seeconfigurator.md§cartographer client). Three rules make it work: - selection happens before the merge, on the per-KB
sync_pullresponses. Filtering the merged manifest is wrong:MergeArtifactshas already discarded candidates, so ifkb-Aoverrides a bundled skill and a provider is bound only tokb-B, the merge keepskb-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 makeComputeDiff.InSynclie, and changing a binding would produce no drift at all.FilterForProvideralso drops kinds the provider has no destination for (e.g.agentforcrush), so the recomputed revision differs from the pre-projection one whenever a provider supports less than the full kind set. The CLI'ssynced to revision …(andwould sync to revision …under--dry-run) reports the revision each provider actually recorded —AppliedResult.NewLock.AppliedRevision, taken frommaterializeForProviders' return value, never the manifestsyncfetched before projection — so it always matches whatstatusreports for that provider (D184); - unbinding removes the artifacts for free: they leave the provider's manifest, so
ComputeDiffmarks themRemovedandPruneManageddeletes them. Only files listed in the lock are touched, so user-owned files survive. A server that does not identify its KBs by name (nokbsin/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.Sourcerecords 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+nameis an error:MergeArtifactsStrict— what the client uses — fails with a report naming the kind, the name and the claiming KBs, before anything is materialized.MergeArtifactsstays tolerant and keeps the alphabeticalsourcetie-break; the server builds a manifest from one KB plus the bundle, where the case cannot arise.cartographer client bindwarns when a new binding creates one, anddoctor'skb-collisionscheck 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).