Client ↔ provisioning synchronization¶
How artifacts served by the server (skills, agents, hooks, instructions, mcp)
reach client providers (claude, opencode, codex, kiro) 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 cartographer-ops (server and client operations), kb-create,
kb-import, and kb-conflict-resolve. 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 allow_artifact_write: true for 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, with 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.
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 bootstrap.sh script (deterministic, silent, always exit 0) checks that cartographer is in PATH and launches cartographer sync --auto-trust in the background. Explicitly excluded from ComputeDiff/Apply (a server manifest will never contain it → it's never treated as an orphan to prune). Details → D60.
| Provider | Hook point | Registration |
|---|---|---|
| claude | SessionStart |
entry in ~/.claude/settings.json |
| codex | [[hooks.SessionStart]] |
managed block in ~/.codex/config.toml |
| opencode | session.created event |
generated plugin in ~/.config/opencode/plugins/ |
| kiro | — (no native hook) | falls back to Layer 2 |
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. |
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. Read-only. |
Layer 3 — Push via MCP notifications (stdio only)¶
On the stdio transport, the server emits notifications/skills/list_changed after skill_install (via notifyWrap, outside gitWrap so it fires after the commit); capability skills.listChanged: true in initialize. Over HTTP, Server.Notify is a no-op: Layers 1-2 still cover this case. Serialized on writeMu, never held during dispatch (verified with -race).
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_pull(once per KB configured in.cartographer.yaml) and merges the manifests withprovisioning.MergeArtifacts; - 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;- 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.
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. Bundled artifacts use the separatebuilt_in:trueorigin. - Explicit unsigned authorization.
trust: trueand one-shot--auto-trustretain the backwards-compatible approval path for eligible unsigned KB artifacts, but never changesigned. 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. - 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.- 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, orBaseDir), which are never removed.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;.kiro/settings/mcp.jsonandopencode.jsonreduced to an empty shell are deleted. Absolute exception:.claude.jsonis never deleted (it's a file shared by Claude Code). For.codex/config.toml, only the marker-delimited block is removed (the file is deleted if it ends up empty). - Round-trip
connect→disconnectleaves no residue, except for the provisioning roots themselves (deliberate boundaries): testTestRoundTrip_ConnectDisconnect_NessunResiduo. Details → D63.
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 provider JSON config merge remains the non-destructive deep-merge of configurator.mergeJSON.
Kind × provider matrix¶
Resolved by destDir(kind, name, provider); an unmapped combination is unsupported (≠ needs_approval: no approval would unblock it) and clients filter it out upstream with FilterForProvider — it counts as neither drift nor pending (D50).
| Kind | claude | opencode | codex | kiro |
|---|---|---|---|---|
skill |
.claude/skills/<name>/ |
.opencode/skills/<name>/ |
.codex/skills/<name>/ |
.kiro/skills/<name>/ |
agent |
.claude/agents/<name>.md (verbatim) |
.opencode/agent/<name>.md (translated) |
.codex/agents/<name>.toml (translated) |
unsupported |
hook |
.claude/hooks/<name>/ + registration in settings.json |
.opencode/hooks/<name>/ + generated JS plugin |
.codex/hooks/<name>/ + block in config.toml |
unsupported |
instructions |
managed block in .claude/CLAUDE.md |
block in .config/opencode/AGENTS.md |
block in .codex/AGENTS.md |
file .kiro/steering/cartographer.md |
mcp |
key mcpServers.<name> in .claude.json |
key mcp.<name> in opencode.json |
block [mcp_servers.<name>] in config.toml |
key mcpServers.<name> in .kiro/settings/mcp.json |
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(verbatim body). Fields that can't be mapped reliably (tools,model) are dropped, not guessed. 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: marker-delimited block
# cartographer:hook:<name>:begin/endin.codex/config.tomlviainternal/blocktext— the TOML is never parsed/re-serialized (D58). Codex rewrites the file and drops the markers with every other comment, so before writing the blockApplyremoves any registration of that hook left outside it — identified by the.codex/hooks/<name>/path in itscommand— which would otherwise make the hook fire twice, and reports the repair inAppliedResult.Warnings(D99); - 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); - a missing/malformed
hook.jsonskips registration without failingApply; acommandwhose first token is a relative path (contains/, e.g../notify.sh) is resolved to the materialized absolute path; bare names (e.g.jq) are left verbatim, resolved via PATH. - 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. - Per-kind counts:
provisioning.KindCounts→ askill 4/5 · agent 2/2 · hook 1/1line incartographer statusand the TUI.
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 archives inline (no page counts — it's stable imprinting, not state, D65), three lines of operational instructions (start with search/atlas_overview, read with concept_read, write with concept_write, close with log_append), 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 archives/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;
- 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 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) up to depth 4, reads each repo's.git/config(nogitexec) and caches the result in~/.config/cartographer/repos.json, refreshed on-miss.{{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).
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 on the expanded content:
ManagedFile.ContentHashin the lockfile is computed on the content after expansion (same formula asContentHashDir/contentHashFile/contentHashBytesdepending on the kind) — the client-side comparison must be against what was actually written to disk. Content with no placeholders → expansion is a no-op, hash identical toArtifact.ContentHash: zero drift for existing installations. - Unresolved placeholder: a warning on stderr (
AppliedResult.Warnings), the text left as-is in the file — sync never blocks on a missing resolution. - "Local paths" table: when
ExpandPlaceholdersis active,applyInstructionsGroupappends to the instructions block (per provider) a placeholder → local path table with every key resolved during that sameApply(agent/skill/hook included, not just the instructions content), plus a fallback instruction for the agent: a placeholder missing from the table →cartographer resolve <key>(docs/configurator.md). - 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: none of the 4 providers 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 leave it verbatim (native syntax); OpenCode translates it to{env:VAR}. 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: claude/opencode/kiro do a JSON merge on themcpServers/mcpkey; 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), with the same empty-shell cleanup asconfigurator.Remove(D63):.kiro/settings/mcp.json/opencode.jsonare deleted if the removal leaves only$schema/nothing;.claude.jsonis never deleted;.codex/config.tomlonly loses the marker-delimited block. - Server policy and client consent: an MCP server receives the agent's data, so two independent grants apply. The server omits it unless the KB's exact
mcp_allowlistentry matches name, transport and target — the normalized URL forhttp, the exact command forstdio; absent/empty policy denies every descriptor. On the client an unsigned descriptor requirescartographer approve mcp <name> --kb <kb>, binding consent to source, name and full content hash.trust/--auto-trustnever authorize MCP. A cryptographically verified descriptor is eligible without a point approval, but is still subject to the server allow-list. Any hash change returns it toneeds_approval; revoke then sync prunes managed provider config. - 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 — never a shell expression: Cartographer passes command and arguments separately
and never starts a shell. Arguments keep their order and env values are ${VAR} references only, so
no secret value ever reaches the server or a provider file. The allow-list binds the exact command,
while the artifact content hash binds every argument and 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) 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; precedence KB > bundle, and among multiple KBs by alphabeticalsource. The manifest holds exactly one artifact perkind+name. - 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).